From ad0b110272e46dfc529e5ce909e189501d6cb92e Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:10:38 +0200 Subject: [PATCH 01/73] feat(api): add equipment management actions - Introduce create, update, and delete actions for equipment - Implement validation schema for equipment data using Zod - Add security checks to ensure user permissions on equipment operations - Create a new service file for managing equipment-related actions - Enhance error handling with specific messages for validation and security issues --- src/app/actions/equipment/manageEquipments.ts | 193 ++++++ .../services/pocketbase/assignmentService.ts | 566 ++++++++++++------ .../services/pocketbase/equipmentService.ts | 269 ++++++--- .../pocketbase/organizationService.ts | 7 +- .../services/pocketbase/projectService.ts | 230 +++++-- .../services/pocketbase/securityUtils.ts | 164 +++++ src/types/types_pocketbase.ts | 7 +- 7 files changed, 1127 insertions(+), 309 deletions(-) create mode 100644 src/app/actions/equipment/manageEquipments.ts create mode 100644 src/app/actions/services/pocketbase/securityUtils.ts diff --git a/src/app/actions/equipment/manageEquipments.ts b/src/app/actions/equipment/manageEquipments.ts new file mode 100644 index 0000000..f9a2347 --- /dev/null +++ b/src/app/actions/equipment/manageEquipments.ts @@ -0,0 +1,193 @@ +'use server' + +import { + createEquipment, + updateEquipment, + deleteEquipment, + generateUniqueCode, +} from '@/app/actions/services/pocketbase/equipmentService' +import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' +import { revalidatePath } from 'next/cache' +import { z } from 'zod' + +// Define validation schema for equipment data +const equipmentSchema = z.object({ + acquisitionDate: z.string().optional(), + name: z.string().min(2, 'Name must be at least 2 characters'), + notes: z.string().optional(), + parentEquipment: z.string().optional(), + tags: z.array(z.string()).optional(), +}) + +type EquipmentFormData = z.infer + +/** + * Result type for all equipment actions + */ +export type EquipmentActionResult = { + success: boolean + message?: string + data?: any + validationErrors?: Record +} + +/** + * Create a new equipment item + */ +export async function createEquipmentAction( + organizationId: string, + formData: EquipmentFormData +): Promise { + try { + // Validate input data + const validatedData = equipmentSchema.parse(formData) + + // Generate unique code for the equipment + const qrNfcCode = await generateUniqueCode() + + // Create the equipment with security checks built into the service + const newEquipment = await createEquipment(organizationId, { + ...validatedData, + qrNfcCode, + }) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + + return { + data: newEquipment, + message: 'Equipment created successfully', + success: true, + } + } catch (error) { + // Handle validation errors + if (error instanceof z.ZodError) { + const validationErrors = error.errors.reduce( + (acc, curr) => { + const key = curr.path.join('.') + acc[key] = curr.message + return acc + }, + {} as Record + ) + + return { + message: 'Validation failed', + success: false, + validationErrors, + } + } + + // Handle security errors + if (error instanceof SecurityError) { + return { + message: error.message, + success: false, + } + } + + // Handle other errors + console.error('Error creating equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +/** + * Update an existing equipment item + */ +export async function updateEquipmentAction( + equipmentId: string, + formData: EquipmentFormData +): Promise { + try { + // Validate input data + const validatedData = equipmentSchema.parse(formData) + + // Update the equipment with security checks built into the service + const updatedEquipment = await updateEquipment(equipmentId, validatedData) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + revalidatePath(`/dashboard/equipment/${equipmentId}`) + + return { + data: updatedEquipment, + message: 'Equipment updated successfully', + success: true, + } + } catch (error) { + // Handle validation errors + if (error instanceof z.ZodError) { + const validationErrors = error.errors.reduce( + (acc, curr) => { + const key = curr.path.join('.') + acc[key] = curr.message + return acc + }, + {} as Record + ) + + return { + message: 'Validation failed', + success: false, + validationErrors, + } + } + + // Handle security errors + if (error instanceof SecurityError) { + return { + message: error.message, + success: false, + } + } + + // Handle other errors + console.error('Error updating equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +/** + * Delete an equipment item + */ +export async function deleteEquipmentAction( + equipmentId: string +): Promise { + try { + // Delete the equipment with security checks built into the service + await deleteEquipment(equipmentId) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + + return { + message: 'Equipment deleted successfully', + success: true, + } + } catch (error) { + // Handle security errors + if (error instanceof SecurityError) { + return { + message: error.message, + success: false, + } + } + + // Handle other errors + console.error('Error deleting equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} diff --git a/src/app/actions/services/pocketbase/assignmentService.ts b/src/app/actions/services/pocketbase/assignmentService.ts index 69dee93..edfea09 100644 --- a/src/app/actions/services/pocketbase/assignmentService.ts +++ b/src/app/actions/services/pocketbase/assignmentService.ts @@ -1,217 +1,445 @@ -'use server'; +'use server' -import { getPocketBase, handlePocketBaseError } from './baseService'; -import { Assignment, ListOptions, ListResult } from './types'; +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { Assignment, ListOptions, ListResult } from '@/types/types_pocketbase' /** - * Get a single assignment by ID + * Get a single assignment by ID with security validation */ export async function getAssignment(id: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getOne(id); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getAssignment'); - } + try { + // Security check - validates user has access to this resource + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('assignments').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'AssignmentService.getAssignment') + } } /** - * Get assignments list with pagination + * Get assignments list with pagination and security checks */ -export async function getAssignmentsList(options: ListOptions = {}): Promise> { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - const { page = 1, perPage = 30, ...rest } = options; - return await pb.collection('assignments').getList(page, perPage, rest); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getAssignmentsList'); - } +export async function getAssignmentsList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('assignments').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.getAssignmentsList') + } } /** - * Get active assignments for an organization + * Get active assignments for an organization with security checks * Active assignments have startDate ≤ current date and no endDate or endDate ≥ current date */ -export async function getActiveAssignments(organizationId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - const now = new Date().toISOString(); - - try { - return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToUser,assignedToProject', - filter: pb.filter( - 'organization = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { now, orgId: organizationId } - ), - sort: '-created', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getActiveAssignments'); - } +export async function getActiveAssignments( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() + + return await pb.collection('assignments').getFullList({ + expand: 'equipment,assignedToUser,assignedToProject', + filter: pb.filter( + 'organization = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', + { now, orgId: organizationId } + ), + sort: '-created', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getActiveAssignments' + ) + } } /** - * Get current assignment for a specific equipment + * Get current assignment for a specific equipment with security checks */ -export async function getCurrentEquipmentAssignment(equipmentId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - const now = new Date().toISOString(); - - try { - const assignments = await pb.collection('assignments').getList(1, 1, { - expand: 'equipment,assignedToUser,assignedToProject', - filter: pb.filter( - 'equipment = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { equipId: equipmentId, now } - ), - sort: '-created', - }); - - return assignments.items.length > 0 ? assignments.items[0] : null; - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getCurrentEquipmentAssignment'); - } +export async function getCurrentEquipmentAssignment( + equipmentId: string +): Promise { + try { + // Security check - validates access to the equipment + const { organizationId } = await validateResourceAccess( + ResourceType.EQUIPMENT, + equipmentId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() + + // Include organization check for extra security + const assignments = await pb.collection('assignments').getList(1, 1, { + expand: 'equipment,assignedToUser,assignedToProject', + filter: pb.filter( + 'organization = {:orgId} && equipment = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', + { equipId: equipmentId, now, orgId: organizationId } + ), + sort: '-created', + }) + + return assignments.items.length > 0 ? assignments.items[0] : null + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getCurrentEquipmentAssignment' + ) + } } /** - * Get assignments for a user + * Get assignments for a user with security checks */ -export async function getUserAssignments(userId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToProject', - filter: `assignedToUser="${userId}"`, - sort: '-created', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getUserAssignments'); - } +export async function getUserAssignments( + userId: string +): Promise { + try { + // Security check - validates access to the user + const { organizationId } = await validateResourceAccess( + ResourceType.USER, + userId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Include organization filter for security + return await pb.collection('assignments').getFullList({ + expand: 'equipment,assignedToProject', + filter: createOrganizationFilter( + organizationId, + `assignedToUser="${userId}"` + ), + sort: '-created', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.getUserAssignments') + } } /** - * Get assignments for a project + * Get assignments for a project with security checks */ -export async function getProjectAssignments(projectId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToUser', - filter: `assignedToProject="${projectId}"`, - sort: '-created', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getProjectAssignments'); - } +export async function getProjectAssignments( + projectId: string +): Promise { + try { + // Security check - validates access to the project + const { organizationId } = await validateResourceAccess( + ResourceType.PROJECT, + projectId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Include organization filter for security + return await pb.collection('assignments').getFullList({ + expand: 'equipment,assignedToUser', + filter: createOrganizationFilter( + organizationId, + `assignedToProject="${projectId}"` + ), + sort: '-created', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getProjectAssignments' + ) + } } /** - * Create a new assignment + * Create a new assignment with security checks */ -export async function createAssignment(data: Partial): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').create(data); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.createAssignment'); - } +export async function createAssignment( + organizationId: string, + data: Omit, 'organization'> +): Promise { + try { + // Security check - requires WRITE permission + const { user } = await validateOrganizationAccess( + organizationId, + PermissionLevel.WRITE + ) + + // If equipment is provided, verify access to it + if (data.equipment) { + await validateResourceAccess( + ResourceType.EQUIPMENT, + data.equipment, + PermissionLevel.READ + ) + } + + // If assignedToUser is provided, verify access to that user + if (data.assignedToUser) { + await validateResourceAccess( + ResourceType.USER, + data.assignedToUser, + PermissionLevel.READ + ) + } + + // If assignedToProject is provided, verify access to that project + if (data.assignedToProject) { + await validateResourceAccess( + ResourceType.PROJECT, + data.assignedToProject, + PermissionLevel.READ + ) + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly + return await pb.collection('assignments').create({ + ...data, + organization: organizationId, // Force the correct organization ID + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.createAssignment') + } } /** - * Update an assignment + * Update an assignment with security checks */ -export async function updateAssignment(id: string, data: Partial): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').update(id, data); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.updateAssignment'); - } +export async function updateAssignment( + id: string, + data: Omit, 'organization' | 'id'> +): Promise { + try { + // Security check - requires WRITE permission for the assignment + const { organizationId } = await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.WRITE + ) + + // Additional validations for related resources + if (data.equipment) { + await validateResourceAccess( + ResourceType.EQUIPMENT, + data.equipment, + PermissionLevel.READ + ) + } + + if (data.assignedToUser) { + await validateResourceAccess( + ResourceType.USER, + data.assignedToUser, + PermissionLevel.READ + ) + } + + if (data.assignedToProject) { + await validateResourceAccess( + ResourceType.PROJECT, + data.assignedToProject, + PermissionLevel.READ + ) + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + delete (sanitizedData as any).organization + + return await pb.collection('assignments').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.updateAssignment') + } } /** - * Delete an assignment + * Delete an assignment with security checks */ export async function deleteAssignment(id: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - await pb.collection('assignments').delete(id); - return true; - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.deleteAssignment'); - } + try { + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('assignments').delete(id) + return true + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.deleteAssignment') + } } /** - * Complete an assignment by setting its end date to now + * Complete an assignment by setting its end date to now with security checks */ export async function completeAssignment(id: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').update(id, { - endDate: new Date().toISOString(), - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.completeAssignment'); - } + try { + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('assignments').update(id, { + endDate: new Date().toISOString(), + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.completeAssignment') + } } /** - * Get assignment history for an equipment + * Get assignment history for an equipment with security checks */ -export async function getEquipmentAssignmentHistory(equipmentId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getFullList({ - expand: 'assignedToUser,assignedToProject', - filter: `equipment=`${equipmentId}``, - sort: '-startDate', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getEquipmentAssignmentHistory'); - } -} \ No newline at end of file +export async function getEquipmentAssignmentHistory( + equipmentId: string +): Promise { + try { + // Security check - validates access to the equipment + const { organizationId } = await validateResourceAccess( + ResourceType.EQUIPMENT, + equipmentId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Include organization filter for security + return await pb.collection('assignments').getFullList({ + expand: 'assignedToUser,assignedToProject', + filter: createOrganizationFilter( + organizationId, + `equipment=`${equipmentId}`` + ), + sort: '-startDate', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getEquipmentAssignmentHistory' + ) + } +} diff --git a/src/app/actions/services/pocketbase/equipmentService.ts b/src/app/actions/services/pocketbase/equipmentService.ts index 596db8b..3062ca2 100644 --- a/src/app/actions/services/pocketbase/equipmentService.ts +++ b/src/app/actions/services/pocketbase/equipmentService.ts @@ -1,80 +1,139 @@ 'use server' -import { getPocketBase, handlePocketBaseError } from './baseService' -import { Equipment, ListOptions, ListResult } from './types' +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { Equipment, ListOptions, ListResult } from '@/types/types_pocketbase' /** - * Get a single equipment item by ID + * Get a single equipment item by ID with security validation */ export async function getEquipment(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - validates user has access to this resource + await validateResourceAccess( + ResourceType.EQUIPMENT, + id, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('equipment').getOne(id) } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } return handlePocketBaseError(error, 'EquipmentService.getEquipment') } } /** - * Get equipment by QR/NFC code + * Get equipment by QR/NFC code with organization validation */ export async function getEquipmentByCode( + organizationId: string, qrNfcCode: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb - .collection('equipment') - .getFirstListItem(`qrNfcCode="${qrNfcCode}"`) + // Security check - validates user belongs to this organization + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter for security + const filter = createOrganizationFilter( + organizationId, + `qrNfcCode="${qrNfcCode}"` + ) + return await pb.collection('equipment').getFirstListItem(filter) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.getEquipmentByCode') } } /** - * Get equipment list with pagination + * Get equipment list with pagination and security checks */ export async function getEquipmentList( + organizationId: string, options: ListOptions = {} ): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('equipment').getList(page, perPage, rest) + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('equipment').getList(page, perPage, { + ...rest, + filter, + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.getEquipmentList') } } /** - * Get all equipment for an organization + * Get all equipment for an organization with security check */ export async function getOrganizationEquipment( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter + const filter = `organization="${organizationId}"` + return await pb.collection('equipment').getFullList({ - filter: `organization="${organizationId}"`, + filter, sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError( error, 'EquipmentService.getOrganizationEquipment' @@ -83,55 +142,93 @@ export async function getOrganizationEquipment( } /** - * Create a new equipment item + * Create a new equipment item with permission check */ export async function createEquipment( - data: Partial + organizationId: string, + data: Omit, 'organization'> ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('equipment').create(data) + // Security check - requires WRITE permission + const { user } = await validateOrganizationAccess( + organizationId, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set and matches the authenticated user's org + return await pb.collection('equipment').create({ + ...data, + organization: organizationId, // Force the correct organization ID + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.createEquipment') } } /** - * Update an equipment item + * Update an equipment item with permission and ownership checks */ export async function updateEquipment( id: string, - data: Partial + data: Omit, 'organization' | 'id'> ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('equipment').update(id, data) + // Security check - validates organization and requires WRITE permission + await validateResourceAccess( + ResourceType.EQUIPMENT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + delete sanitizedData.organization + + return await pb.collection('equipment').update(id, sanitizedData) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.updateEquipment') } } /** - * Delete an equipment item + * Delete an equipment item with permission check */ export async function deleteEquipment(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - requires ADMIN permission for deletion + await validateResourceAccess( + ResourceType.EQUIPMENT, + id, + PermissionLevel.ADMIN + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + await pb.collection('equipment').delete(id) return true } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.deleteEquipment') } } @@ -142,17 +239,33 @@ export async function deleteEquipment(id: string): Promise { export async function getChildEquipment( parentId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - validates parent equipment access + const { organizationId } = await validateResourceAccess( + ResourceType.EQUIPMENT, + parentId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter for security + const filter = createOrganizationFilter( + organizationId, + `parentEquipment="${parentId}"` + ) + return await pb.collection('equipment').getFullList({ - filter: `parentEquipment="${parentId}"`, + filter, sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.getChildEquipment') } } @@ -173,35 +286,44 @@ export async function generateUniqueCode(): Promise { export async function getEquipmentCount( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + const result = await pb.collection('equipment').getList(1, 1, { - filter: `organization="${organizationId}"`, + filter: `organization=${organizationId}`, skipTotal: false, }) return result.totalItems } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.getEquipmentCount') } } /** - * Search equipment by name or tag + * Search equipment by name or tag within organization */ export async function searchEquipment( organizationId: string, query: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('equipment').getFullList({ filter: pb.filter( 'organization = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', @@ -213,6 +335,9 @@ export async function searchEquipment( sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'EquipmentService.searchEquipment') } } diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts index af7644f..11a9e7a 100644 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ b/src/app/actions/services/pocketbase/organizationService.ts @@ -1,7 +1,10 @@ 'use server' -import { getPocketBase, handlePocketBaseError } from './baseService' -import { ListOptions, ListResult, Organization } from './types' +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' /** * Get a single organization by ID diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts index 7b2238f..318ecb0 100644 --- a/src/app/actions/services/pocketbase/projectService.ts +++ b/src/app/actions/services/pocketbase/projectService.ts @@ -1,60 +1,105 @@ 'use server' -import { getPocketBase, handlePocketBaseError } from './baseService' -import { ListOptions, ListResult, Project } from './types' +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' /** - * Get a single project by ID + * Get a single project by ID with security validation */ export async function getProject(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.PROJECT, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('projects').getOne(id) } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } return handlePocketBaseError(error, 'ProjectService.getProject') } } /** - * Get projects list with pagination + * Get projects list with pagination and security checks */ export async function getProjectsList( + organizationId: string, options: ListOptions = {} ): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('projects').getList(page, perPage, rest) + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('projects').getList(page, perPage, { + ...rest, + filter, + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.getProjectsList') } } /** - * Get all projects for an organization + * Get all projects for an organization with security checks */ export async function getOrganizationProjects( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter + const filter = `organization="${organizationId}"` + return await pb.collection('projects').getFullList({ - filter: `organization=`${organizationId}``, + filter, sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError( error, 'ProjectService.getOrganizationProjects' @@ -63,19 +108,23 @@ export async function getOrganizationProjects( } /** - * Get active projects (current date is between startDate and endDate or endDate is not set) + * Get active projects with security checks + * (current date is between startDate and endDate or endDate is not set) */ export async function getActiveProjects( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) - const now = new Date().toISOString() + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() - try { return await pb.collection('projects').getFullList({ filter: pb.filter( 'organization = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', @@ -84,95 +133,145 @@ export async function getActiveProjects( sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.getActiveProjects') } } /** - * Create a new project + * Create a new project with security checks */ -export async function createProject(data: Partial): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - +export async function createProject( + organizationId: string, + data: Omit, 'organization'> +): Promise { try { - return await pb.collection('projects').create(data) + // Security check - requires WRITE permission + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly + return await pb.collection('projects').create({ + ...data, + organization: organizationId, // Force the correct organization ID + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.createProject') } } /** - * Update a project + * Update a project with security checks */ export async function updateProject( id: string, - data: Partial + data: Omit, 'organization' | 'id'> ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('projects').update(id, data) + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.PROJECT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + delete (sanitizedData as any).organization + + return await pb.collection('projects').update(id, sanitizedData) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.updateProject') } } /** - * Delete a project + * Delete a project with security checks */ export async function deleteProject(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - requires ADMIN permission for deletion + await validateResourceAccess( + ResourceType.PROJECT, + id, + PermissionLevel.ADMIN + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + await pb.collection('projects').delete(id) return true } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.deleteProject') } } /** - * Get project count for an organization + * Get project count for an organization with security checks */ export async function getProjectCount(organizationId: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + const result = await pb.collection('projects').getList(1, 1, { filter: `organization="${organizationId}"`, skipTotal: false, }) + return result.totalItems } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.getProjectCount') } } /** - * Search projects by name or address + * Search projects by name or address with security checks */ export async function searchProjects( organizationId: string, query: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('projects').getFullList({ filter: pb.filter( 'organization = {:orgId} && (name ~ {:query} || address ~ {:query})', @@ -184,6 +283,9 @@ export async function searchProjects( sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.searchProjects') } } diff --git a/src/app/actions/services/pocketbase/securityUtils.ts b/src/app/actions/services/pocketbase/securityUtils.ts new file mode 100644 index 0000000..d214389 --- /dev/null +++ b/src/app/actions/services/pocketbase/securityUtils.ts @@ -0,0 +1,164 @@ +'use server' + +import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +import { User } from '@/types/types_pocketbase' +import { auth } from '@clerk/nextjs/server' + +/** + * User permission levels + */ +export enum PermissionLevel { + ADMIN = 'admin', + READ = 'read', + WRITE = 'write', +} + +/** + * Resource types for permission checks + */ +export enum ResourceType { + ASSIGNMENT = 'assignment', + EQUIPMENT = 'equipment', + ORGANIZATION = 'organization', + PROJECT = 'project', + USER = 'user', +} + +/** + * Error thrown when security checks fail + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message) + this.name = 'SecurityError' + } +} + +/** + * Validates a user ID against the current authenticated user + * @param userId The user ID to validate + * @throws {SecurityError} If the user ID is invalid or unauthorized + */ +export async function validateCurrentUser(userId?: string): Promise { + // Get Clerk auth context + const { userId: clerkUserId } = await auth() + + if (!clerkUserId) { + throw new SecurityError('Unauthenticated access') + } + + const pb = await getPocketBase() + if (!pb) { + throw new SecurityError('Database connection error') + } + + try { + // Find the user by Clerk ID + const user = await pb + .collection('users') + .getFirstListItem(`clerkId=${clerkUserId}`) + + // If a specific user ID was provided, verify it matches the current user + if (userId && user.id !== userId) { + throw new SecurityError('Unauthorized access to user data') + } + + return user + } catch (error) { + console.error('User validation error:', error) + throw new SecurityError('Failed to validate user') + } +} + +/** + * Validates organizational access and permissions + * @param organizationId The organization ID to validate + * @param permission The required permission level + * @returns The validated user and organization + * @throws {SecurityError} If access is unauthorized + */ +export async function validateOrganizationAccess( + organizationId: string, + permission: PermissionLevel = PermissionLevel.READ +): Promise<{ user: User; organizationId: string }> { + // Get authenticated user + const user = await validateCurrentUser() + + // Check organization membership + if (user.expand?.organizationId !== organizationId) { + throw new SecurityError('Unauthorized access to organization data') + } + + // Check permission level + if ( + permission === PermissionLevel.ADMIN && + !user.isAdmin && + user.role !== 'admin' + ) { + throw new SecurityError('Insufficient permissions for this operation') + } + + if ( + permission === PermissionLevel.WRITE && + !user.isAdmin && + user.role !== 'admin' && + user.role !== 'manager' + ) { + throw new SecurityError('Insufficient permissions for this operation') + } + + return { organizationId, user } +} + +/** + * Validates resource access (equipment, project, assignment) + * @param resourceType The type of resource + * @param resourceId The resource ID + * @param permission The required permission level + * @returns The validated user and organization ID + * @throws {SecurityError} If access is unauthorized + */ +export async function validateResourceAccess( + resourceType: ResourceType, + resourceId: string, + permission: PermissionLevel = PermissionLevel.READ +): Promise<{ user: User; organizationId: string }> { + const pb = await getPocketBase() + if (!pb) { + throw new SecurityError('Database connection error') + } + + try { + // Fetch the resource to check organization membership + const resource = await pb.collection(resourceType).getOne(resourceId) + + // Now validate organization access with the required permission + return validateOrganizationAccess(resource.organization, permission) + } catch (error) { + console.error( + `Resource validation error (${resourceType}/${resourceId}):`, + error + ) + throw new SecurityError('Failed to validate resource access') + } +} + +/** + * Creates a secure organization filter + * Ensures that all queries include organization-level filtering + * @param organizationId The organization ID to filter by + * @param additionalFilter Optional additional filter expression + * @returns A complete filter string with organization filtering + */ +export function createOrganizationFilter( + organizationId: string, + additionalFilter?: string +): string { + const orgFilter = `organization="${organizationId}"` + + if (!additionalFilter) { + return orgFilter + } + + return `${orgFilter} && (${additionalFilter})` +} diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index 8a9082c..53ce642 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -51,7 +51,10 @@ export interface User extends BaseModel { clerkId?: string // Expanded relations - expand?: Record + // the user can be part of multiple organizations + expand?: { + organizationId?: Organization[] + } } /** @@ -118,7 +121,7 @@ export interface Image extends BaseModel { title: string | null alt: string | null caption: string | null - image: string | null + image: string | null // Expanded relations expand?: Record From 7bb3b97363c396cd7167b9768321783c2111364a Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:12:05 +0200 Subject: [PATCH 02/73] feat(deps): update package versions for improvements - Upgrade several dependencies to their latest versions - Include new packages like @radix-ui/react-icons and canvas-confetti - Update React and related libraries to enhance performance - Improve ESLint and TypeScript tooling with newer versions --- package-lock.json | 851 ++++++++++++++++++---------------------------- 1 file changed, 325 insertions(+), 526 deletions(-) diff --git a/package-lock.json b/package-lock.json index d37344f..218a9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,54 +8,58 @@ "name": "for-tooling", "version": "0.1.0", "dependencies": { - "@clerk/nextjs": "^6.12.6", - "@eslint/js": "^9.22.0", - "@headlessui/react": "^2.2.0", - "@heroicons/react": "^2.2.0", - "@radix-ui/react-avatar": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.8", - "autoprefixer": "10.4.20", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dayjs": "^1.11.13", - "framer-motion": "12.4.10", - "heroicons": "^2.2.0", - "lucide-react": "^0.483.0", - "next": "15.2.2", - "postcss": "^8.5.3", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-use-measure": "^2.1.7", - "tailwind-merge": "^3.0.2", - "tw-animate-css": "^1.2.4", + "@clerk/nextjs": "6.12.12", + "@eslint/js": "9.23.0", + "@headlessui/react": "2.2.0", + "@heroicons/react": "2.2.0", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "@types/canvas-confetti": "1.9.0", + "autoprefixer": "10.4.21", + "canvas-confetti": "1.9.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "dayjs": "1.11.13", + "framer-motion": "12.6.2", + "heroicons": "2.2.0", + "lucide-react": "0.485.0", + "next": "15.2.4", + "pocketbase": "0.25.2", + "postcss": "8.5.3", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-use-measure": "2.1.7", + "tailwind-merge": "3.0.2", + "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.0", - "@next/eslint-plugin-next": "15.2.1", - "@tailwindcss/postcss": "^4.0.14", - "@types/node": "22.13.10", - "@types/react": "19.0.10", + "@eslint/eslintrc": "3.3.1", + "@next/eslint-plugin-next": "15.2.4", + "@tailwindcss/postcss": "4.0.17", + "@types/node": "22.13.14", + "@types/react": "19.0.12", "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "eslint": "^9.22.0", - "eslint-config-next": "15.2.1", + "@typescript-eslint/eslint-plugin": "8.28.0", + "@typescript-eslint/parser": "8.28.0", + "eslint": "9.23.0", + "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.0", - "eslint-plugin-prettier": "5.2.3", + "eslint-plugin-perfectionist": "4.10.1", + "eslint-plugin-prettier": "5.2.5", "eslint-plugin-react": "7.37.4", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "^4.0.14", - "tailwindcss-animate": "^1.0.7", + "tailwindcss": "4.0.17", + "tailwindcss-animate": "1.0.7", "typescript": "5.8.2", - "typescript-eslint": "8.26.1" + "typescript-eslint": "8.28.0" } }, "node_modules/@alloc/quick-lru": { @@ -72,13 +76,13 @@ } }, "node_modules/@clerk/backend": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.25.4.tgz", - "integrity": "sha512-rgtijAqovktwLDnuO0rP5Iln0qJKGkm5yNWFaVIGZescssvBG9VUvbTYt/TvyyzqNsAWyyT2WmnAP24WTwqBTQ==", + "version": "1.25.8", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.25.8.tgz", + "integrity": "sha512-DmIc5pNQeTLHLCLN8ajcNhYNCfqmvwSwyGqr5aCHiJdWqGb9DGaws7PXU9btBiXVbI+NK/CJwjGv09+2rGpgAg==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.1.0", - "@clerk/types": "^4.49.1", + "@clerk/shared": "^3.2.3", + "@clerk/types": "^4.50.1", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" @@ -88,13 +92,13 @@ } }, "node_modules/@clerk/clerk-react": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.25.1.tgz", - "integrity": "sha512-tyfmCXjmGPhmoZaszf0072waJsr4rWlrxYbWkP9nxwrPGkMk6bHR/xI6EyDi5lQGCwu2ICvM+zKo4ZvL43DXmA==", + "version": "5.25.5", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.25.5.tgz", + "integrity": "sha512-euG4T9EaN4af4YH7N8Fl6hIKnXQl+KSZv1WTLgD4KP90hSpVTMPkhdWeOiRFpNQ5I6WwtkaUPY16nce5y/NTQA==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.1.0", - "@clerk/types": "^4.49.1", + "@clerk/shared": "^3.2.3", + "@clerk/types": "^4.50.1", "tslib": "2.8.1" }, "engines": { @@ -106,15 +110,15 @@ } }, "node_modules/@clerk/nextjs": { - "version": "6.12.7", - "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.12.7.tgz", - "integrity": "sha512-r5/V2t3kqPSGhPRsOUQTO19qDnX7q/2ITJYE4R10ifaXjsiVvwr1+UncXT9hKVFAc7W4wCWOV/7LDphnIEt4jQ==", + "version": "6.12.12", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.12.12.tgz", + "integrity": "sha512-V1Vb1a5pTZArNrCy/YvpXCFQZsrRb54G+crzZ55kiuqvPaVGPguEoSCqjoaJ1RolyagXMhLKvht3Te6DYMSZEg==", "license": "MIT", "dependencies": { - "@clerk/backend": "^1.25.4", - "@clerk/clerk-react": "^5.25.1", - "@clerk/shared": "^3.1.0", - "@clerk/types": "^4.49.1", + "@clerk/backend": "^1.25.8", + "@clerk/clerk-react": "^5.25.5", + "@clerk/shared": "^3.2.3", + "@clerk/types": "^4.50.1", "server-only": "0.0.1", "tslib": "2.8.1" }, @@ -122,19 +126,19 @@ "node": ">=18.17.0" }, "peerDependencies": { - "next": "^13.5.4 || ^14.0.3 || ^15.0.0", + "next": "^13.5.7 || ^14.2.25 || ^15.2.3", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "node_modules/@clerk/shared": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.1.0.tgz", - "integrity": "sha512-cCQhmu4yXl/qqY84p+q8szm8rwdMVVxPDoqfLggU9+UefsLEa8rD3lbD1MSD8Yrou8L7jsvx9zmSGw3gBSVXyw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.2.3.tgz", + "integrity": "sha512-F8P7SqpcaLTV/wwCB3/1AkboO3YqFjb7qS6GoSDtVTFHMfpHJgHKhZ0vUBQFaLh/8ZV1kyRuiI/hrrbwIOF1EQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@clerk/types": "^4.49.1", + "@clerk/types": "^4.50.1", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", @@ -158,9 +162,9 @@ } }, "node_modules/@clerk/types": { - "version": "4.49.1", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.49.1.tgz", - "integrity": "sha512-eVxDDvf4D36lFp5fWek6P+bTeZa4c4KAAlo3sE7Ga2lIsnhot9p+p+ugqeP/Y5EgOmj3+uy1nwvpcgZ4oV93PA==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.50.1.tgz", + "integrity": "sha512-GwsW/6LPHavHghh2QpmDbhyIuDP61OYV0T6x5hnjgAxjfexpRymbewR7Qez7H4kOo4gtnCNUrgTZ6nyresLEEg==", "license": "MIT", "dependencies": { "csstype": "3.1.3" @@ -170,9 +174,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.0.tgz", + "integrity": "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==", "license": "MIT", "optional": true, "dependencies": { @@ -237,9 +241,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", + "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -260,9 +264,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -284,9 +288,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", + "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -825,15 +829,15 @@ } }, "node_modules/@next/env": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.2.tgz", - "integrity": "sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", + "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.2.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.1.tgz", - "integrity": "sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.4.tgz", + "integrity": "sha512-O8ScvKtnxkp8kL9TpJTTKnMqlkZnS+QxwoQnJwPGBxjBbzd6OVVPEJ5/pMNrktSyXQD/chEfzfFzYLM6JANOOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -841,9 +845,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.2.tgz", - "integrity": "sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", + "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", "cpu": [ "arm64" ], @@ -857,9 +861,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.2.tgz", - "integrity": "sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", + "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", "cpu": [ "x64" ], @@ -873,9 +877,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.2.tgz", - "integrity": "sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", + "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", "cpu": [ "arm64" ], @@ -889,9 +893,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.2.tgz", - "integrity": "sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", + "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", "cpu": [ "arm64" ], @@ -905,9 +909,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.2.tgz", - "integrity": "sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", + "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", "cpu": [ "x64" ], @@ -921,9 +925,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.2.tgz", - "integrity": "sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", + "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", "cpu": [ "x64" ], @@ -937,9 +941,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.2.tgz", - "integrity": "sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", + "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", "cpu": [ "arm64" ], @@ -953,9 +957,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.2.tgz", - "integrity": "sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", + "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", "cpu": [ "x64" ], @@ -1017,9 +1021,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", - "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", + "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", "dev": true, "license": "MIT", "engines": { @@ -1217,6 +1221,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -1671,44 +1684,44 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.15.tgz", - "integrity": "sha512-IODaJjNmiasfZX3IoS+4Em3iu0fD2HS0/tgrnkYfW4hyUor01Smnr5eY3jc4rRgaTDrJlDmBTHbFO0ETTDaxWA==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.17.tgz", + "integrity": "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg==", "dev": true, "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.15" + "tailwindcss": "4.0.17" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.15.tgz", - "integrity": "sha512-e0uHrKfPu7JJGMfjwVNyt5M0u+OP8kUmhACwIRlM+JNBuReDVQ63yAD1NWe5DwJtdaHjugNBil76j+ks3zlk6g==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.17.tgz", + "integrity": "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.15", - "@tailwindcss/oxide-darwin-arm64": "4.0.15", - "@tailwindcss/oxide-darwin-x64": "4.0.15", - "@tailwindcss/oxide-freebsd-x64": "4.0.15", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.15", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.15", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.15", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.15", - "@tailwindcss/oxide-linux-x64-musl": "4.0.15", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.15", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.15" + "@tailwindcss/oxide-android-arm64": "4.0.17", + "@tailwindcss/oxide-darwin-arm64": "4.0.17", + "@tailwindcss/oxide-darwin-x64": "4.0.17", + "@tailwindcss/oxide-freebsd-x64": "4.0.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.17", + "@tailwindcss/oxide-linux-x64-musl": "4.0.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.17" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.15.tgz", - "integrity": "sha512-EBuyfSKkom7N+CB3A+7c0m4+qzKuiN0WCvzPvj5ZoRu4NlQadg/mthc1tl5k9b5ffRGsbDvP4k21azU4VwVk3Q==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.17.tgz", + "integrity": "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg==", "cpu": [ "arm64" ], @@ -1723,9 +1736,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.15.tgz", - "integrity": "sha512-ObVAnEpLepMhV9VoO0JSit66jiN5C4YCqW3TflsE9boo2Z7FIjV80RFbgeL2opBhtxbaNEDa6D0/hq/EP03kgQ==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.17.tgz", + "integrity": "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg==", "cpu": [ "arm64" ], @@ -1740,9 +1753,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.15.tgz", - "integrity": "sha512-IElwoFhUinOr9MyKmGTPNi1Rwdh68JReFgYWibPWTGuevkHkLWKEflZc2jtI5lWZ5U9JjUnUfnY43I4fEXrc4g==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.17.tgz", + "integrity": "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ==", "cpu": [ "x64" ], @@ -1757,9 +1770,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.15.tgz", - "integrity": "sha512-6BLLqyx7SIYRBOnTZ8wgfXANLJV5TQd3PevRJZp0vn42eO58A2LykRKdvL1qyPfdpmEVtF+uVOEZ4QTMqDRAWA==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.17.tgz", + "integrity": "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ==", "cpu": [ "x64" ], @@ -1774,9 +1787,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.15.tgz", - "integrity": "sha512-Zy63EVqO9241Pfg6G0IlRIWyY5vNcWrL5dd2WAKVJZRQVeolXEf1KfjkyeAAlErDj72cnyXObEZjMoPEKHpdNw==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.17.tgz", + "integrity": "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ==", "cpu": [ "arm" ], @@ -1791,9 +1804,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.15.tgz", - "integrity": "sha512-2NemGQeaTbtIp1Z2wyerbVEJZTkAWhMDOhhR5z/zJ75yMNf8yLnE+sAlyf6yGDNr+1RqvWrRhhCFt7i0CIxe4Q==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.17.tgz", + "integrity": "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g==", "cpu": [ "arm64" ], @@ -1808,9 +1821,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.15.tgz", - "integrity": "sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.17.tgz", + "integrity": "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w==", "cpu": [ "arm64" ], @@ -1825,9 +1838,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.15.tgz", - "integrity": "sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.17.tgz", + "integrity": "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg==", "cpu": [ "x64" ], @@ -1842,9 +1855,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.15.tgz", - "integrity": "sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.17.tgz", + "integrity": "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw==", "cpu": [ "x64" ], @@ -1859,9 +1872,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.15.tgz", - "integrity": "sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.17.tgz", + "integrity": "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw==", "cpu": [ "arm64" ], @@ -1876,9 +1889,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.15.tgz", - "integrity": "sha512-JQ5H+5MLhOjpgNp6KomouE0ZuKmk3hO5h7/ClMNAQ8gZI2zkli3IH8ZqLbd2DVfXDbdxN2xvooIEeIlkIoSCqw==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.17.tgz", + "integrity": "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg==", "cpu": [ "x64" ], @@ -1893,18 +1906,18 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.15.tgz", - "integrity": "sha512-qyrpoDKIO7wzkRbKCvGLo7gXRjT9/Njf7ZJiJhG4njrfZkvOhjwnaHpYbpxYeDysEg+9pB1R4jcd+vQ7ZUDsmQ==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.17.tgz", + "integrity": "sha512-qeJbRTB5FMZXmuJF+eePd235EGY6IyJZF0Bh0YM6uMcCI4L9Z7dy+lPuLAhxOJzxnajsbjPoDAKOuAqZRtf1PQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.0.15", - "@tailwindcss/oxide": "4.0.15", + "@tailwindcss/node": "4.0.17", + "@tailwindcss/oxide": "4.0.17", "lightningcss": "1.29.2", "postcss": "^8.4.41", - "tailwindcss": "4.0.15" + "tailwindcss": "4.0.17" } }, "node_modules/@tanstack/react-virtual": { @@ -1934,6 +1947,12 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1956,9 +1975,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", - "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "version": "22.13.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", + "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", "dev": true, "license": "MIT", "dependencies": { @@ -1966,9 +1985,9 @@ } }, "node_modules/@types/react": { - "version": "19.0.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", - "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "version": "19.0.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", + "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1986,17 +2005,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", - "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", + "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/type-utils": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/type-utils": "8.28.0", + "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2016,16 +2035,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", - "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", + "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "engines": { @@ -2041,14 +2060,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", - "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", + "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0" + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2059,14 +2078,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", - "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", + "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/utils": "8.26.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -2083,9 +2102,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", - "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", + "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", "dev": true, "license": "MIT", "engines": { @@ -2097,14 +2116,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", - "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", + "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2180,16 +2199,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", + "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0" + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2204,13 +2223,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", - "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", + "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2481,9 +2500,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "funding": [ { "type": "opencollective", @@ -2500,11 +2519,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -2707,6 +2726,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", + "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3248,19 +3277,19 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", + "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", + "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.23.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3309,13 +3338,13 @@ } }, "node_modules/eslint-config-next": { - "version": "15.2.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.2.1.tgz", - "integrity": "sha512-mhsprz7l0no8X+PdDnVHF4dZKu9YBJp2Rf6ztWbXBLJ4h6gxmW//owbbGJMBVUU+PibGJDAqZhW4pt8SC8HSow==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.2.4.tgz", + "integrity": "sha512-v4gYjd4eYIme8qzaJItpR5MMBXJ0/YV07u7eb50kEnlEmX7yhOjdUdzz70v4fiINYRjLf8X8TbogF0k7wlz6sA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.2.1", + "@next/eslint-plugin-next": "15.2.4", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -3519,9 +3548,9 @@ } }, "node_modules/eslint-plugin-perfectionist": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.10.0.tgz", - "integrity": "sha512-7sH4rXjIS6ekf/9YL25099Ja09aTqKM00VmN0de/JicSFU5h0GmkjpYuqm1stti0L/baDos7jcTbxt28o1pkJw==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.10.1.tgz", + "integrity": "sha512-GXwFfL47RfBLZRGQdrvGZw9Ali2T2GPW8p4Gyj2fyWQ9396R/HgJMf0m9kn7D6WXRwrINfTDGLS+QYIeok9qEg==", "dev": true, "license": "MIT", "dependencies": { @@ -3537,14 +3566,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", - "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", + "integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.9.1" + "synckit": "^0.10.2" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3555,7 +3584,7 @@ "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", - "eslint-config-prettier": "*", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { @@ -3897,13 +3926,13 @@ } }, "node_modules/framer-motion": { - "version": "12.4.10", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.10.tgz", - "integrity": "sha512-3Msuyjcr1Pb5hjkn4EJcRe1HumaveP0Gbv4DBMKTPKcV/1GSMkQXj+Uqgneys+9DPcZM18Hac9qY9iUEF5LZtg==", + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.2.tgz", + "integrity": "sha512-7LgPRlPs5aG8UxeZiMCMZz8firC53+2+9TnWV22tuSi38D3IFRxHRUqOREKckAkt6ztX+Dn6weLcatQilJTMcg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.4.10", - "motion-utils": "^12.4.10", + "motion-dom": "^12.6.1", + "motion-utils": "^12.5.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5132,9 +5161,9 @@ } }, "node_modules/lucide-react": { - "version": "0.483.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.483.0.tgz", - "integrity": "sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA==", + "version": "0.485.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.485.0.tgz", + "integrity": "sha512-NvyQJ0LKyyCxL23nPKESlr/jmz8r7fJO1bkuptSNYSy0s8VVj4ojhX0YAgmE1e0ewfxUZjIlZpvH+otfTnla8Q==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5210,9 +5239,9 @@ } }, "node_modules/motion-dom": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.5.0.tgz", - "integrity": "sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==", + "version": "12.6.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.1.tgz", + "integrity": "sha512-8XVsriTUEVOepoIDgE/LDGdg7qaKXWdt+wQA/8z0p8YzJDLYL8gbimZ3YkCLlj7bB2i/4UBD/g+VO7y9ZY0zHQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.5.0" @@ -5267,12 +5296,12 @@ } }, "node_modules/next": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.2.tgz", - "integrity": "sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", + "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", "license": "MIT", "dependencies": { - "@next/env": "15.2.2", + "@next/env": "15.2.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -5287,14 +5316,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.2", - "@next/swc-darwin-x64": "15.2.2", - "@next/swc-linux-arm64-gnu": "15.2.2", - "@next/swc-linux-arm64-musl": "15.2.2", - "@next/swc-linux-x64-gnu": "15.2.2", - "@next/swc-linux-x64-musl": "15.2.2", - "@next/swc-win32-arm64-msvc": "15.2.2", - "@next/swc-win32-x64-msvc": "15.2.2", + "@next/swc-darwin-arm64": "15.2.4", + "@next/swc-darwin-x64": "15.2.4", + "@next/swc-linux-arm64-gnu": "15.2.4", + "@next/swc-linux-arm64-musl": "15.2.4", + "@next/swc-linux-x64-gnu": "15.2.4", + "@next/swc-linux-x64-musl": "15.2.4", + "@next/swc-win32-arm64-msvc": "15.2.4", + "@next/swc-win32-x64-msvc": "15.2.4", "sharp": "^0.33.5" }, "peerDependencies": { @@ -5622,6 +5651,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pocketbase": { + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.25.2.tgz", + "integrity": "sha512-ONZl1+qHJMnhR2uacBlBJ90lm7njtL/zy0606+1ROfK9hSL4LRBRc8r89rMcNRzPzRqCNyoFTh2Qg/lYXdEC1w==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5828,24 +5863,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", "dependencies": { - "scheduler": "^0.25.0" + "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^19.0.0" + "react": "^19.1.0" } }, "node_modules/react-is": { @@ -6115,9 +6150,9 @@ } }, "node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, "node_modules/semver": { @@ -6590,14 +6625,14 @@ } }, "node_modules/synckit": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", - "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", + "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" + "@pkgr/core": "^0.2.0", + "tslib": "^2.8.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -6623,9 +6658,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.15.tgz", - "integrity": "sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz", + "integrity": "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==", "dev": true, "license": "MIT" }, @@ -6740,9 +6775,9 @@ "license": "0BSD" }, "node_modules/tw-animate-css": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.4.tgz", - "integrity": "sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", + "integrity": "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" @@ -6762,9 +6797,9 @@ } }, "node_modules/type-fest": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", - "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", + "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -6866,44 +6901,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.1.tgz", - "integrity": "sha512-t/oIs9mYyrwZGRpDv3g+3K6nZ5uhKEMt2oNmAPwaY4/ye0+EH4nXIPYNtkYFS6QHm+1DFg34DbglYBz5P9Xysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.26.1", - "@typescript-eslint/parser": "8.26.1", - "@typescript-eslint/utils": "8.26.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", - "integrity": "sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.28.0.tgz", + "integrity": "sha512-jfZtxJoHm59bvoCMYCe2BM0/baMswRhMmYhy+w6VfcyHrjxZ0OJe0tGasydCpIpA+A/WIJhTyZfb3EtwNC/kHQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.1", - "@typescript-eslint/type-utils": "8.26.1", - "@typescript-eslint/utils": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/eslint-plugin": "8.28.0", + "@typescript-eslint/parser": "8.28.0", + "@typescript-eslint/utils": "8.28.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6913,217 +6919,10 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.1.tgz", - "integrity": "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.26.1", - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/typescript-estree": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.1.tgz", - "integrity": "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.1.tgz", - "integrity": "sha512-Kcj/TagJLwoY/5w9JGEFV0dclQdyqw9+VMndxOJKtoFSjfZhLXhYjzsQEeyza03rwHx2vFEGvrJWJBXKleRvZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.1", - "@typescript-eslint/utils": "8.26.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.1.tgz", - "integrity": "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.1.tgz", - "integrity": "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.1.tgz", - "integrity": "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.1", - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/typescript-estree": "8.26.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.1.tgz", - "integrity": "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.26.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/typescript-eslint/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", From 21d120da67836818ea7da56864b42c0af90b84c0 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:13:41 +0200 Subject: [PATCH 03/73] feat(api): enhance security validations for user and organization - Add security checks for accessing organizations and users - Implement validation for current user access in various functions - Restrict certain operations to super-admins only - Introduce new methods to get current organization settings and check admin status - Update existing functions to ensure proper permission handling --- .../services/pocketbase/assignmentService.ts | 3 +- .../pocketbase/organizationService.ts | 211 +++++++++----- .../services/pocketbase/userService.ts | 270 ++++++++++++++---- 3 files changed, 360 insertions(+), 124 deletions(-) diff --git a/src/app/actions/services/pocketbase/assignmentService.ts b/src/app/actions/services/pocketbase/assignmentService.ts index edfea09..5453f86 100644 --- a/src/app/actions/services/pocketbase/assignmentService.ts +++ b/src/app/actions/services/pocketbase/assignmentService.ts @@ -146,6 +146,7 @@ export async function getCurrentEquipmentAssignment( sort: '-created', }) + // todo: fix type error return assignments.items.length > 0 ? assignments.items[0] : null } catch (error) { if (error instanceof SecurityError) { @@ -429,7 +430,7 @@ export async function getEquipmentAssignmentHistory( expand: 'assignedToUser,assignedToProject', filter: createOrganizationFilter( organizationId, - `equipment=`${equipmentId}`` + `equipment=${equipmentId}` ), sort: '-startDate', }) diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts index 11a9e7a..fcfcac0 100644 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ b/src/app/actions/services/pocketbase/organizationService.ts @@ -4,40 +4,69 @@ import { getPocketBase, handlePocketBaseError, } from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + validateResourceAccess, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' /** - * Get a single organization by ID + * Get a single organization by ID with security validation */ export async function getOrganization(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - validates user has access to this organization + await validateOrganizationAccess(id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('organizations').getOne(id) } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } return handlePocketBaseError(error, 'OrganizationService.getOrganization') } } /** - * Get an organization by Clerk ID + * Get an organization by Clerk ID with security validation + * This is primarily used during authentication */ export async function getOrganizationByClerkId( clerkId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb + // This endpoint is typically called during authentication + // We still validate the current user is authenticated + const user = await validateCurrentUser() + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const organization = await pb .collection('organizations') - .getFirstListItem(`clerkId="${clerkId}"`) + .getFirstListItem(`clerkId=`${clerkId}``) + + // After fetching, verify that the user belongs to this organization + if (user.organization !== organization.id) { + throw new SecurityError('User does not belong to this organization') + } + + return organization } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError( error, 'OrganizationService.getOrganizationByClerkId' @@ -47,62 +76,64 @@ export async function getOrganizationByClerkId( /** * Get organizations list with pagination + * This should only be accessible to super-admins, so we don't implement it + * in a regular multi-tenant app */ export async function getOrganizationsList( options: ListOptions = {} ): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('organizations').getList(page, perPage, rest) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationsList' - ) - } + // This function should be restricted to super-admins only + throw new SecurityError( + 'This operation is restricted to super administrators' + ) } /** * Create a new organization + * This should only be done during onboarding or by super-admins */ export async function createOrganization( data: Partial ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('organizations').create(data) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.createOrganization' - ) - } + // For creating organizations, we typically handle this specially + // during onboarding with Clerk. This should not be exposed to regular users. + throw new SecurityError('This operation is restricted') } /** - * Update an organization + * Update an organization with security validation */ export async function updateOrganization( id: string, data: Partial ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('organizations').update(id, data) + // Security check - requires ADMIN permission for organization updates + await validateOrganizationAccess(id, PermissionLevel.ADMIN) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Sanitize sensitive fields + const sanitizedData = { ...data } + + // Never allow changing the clerkId - that's a special binding + delete sanitizedData.clerkId + + // Don't allow changing Stripe-related fields directly + // These should only be updated by the Stripe webhook + delete sanitizedData.stripeCustomerId + delete sanitizedData.subscriptionId + delete sanitizedData.subscriptionStatus + delete sanitizedData.priceId + + return await pb.collection('organizations').update(id, sanitizedData) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError( error, 'OrganizationService.updateOrganization' @@ -112,26 +143,17 @@ export async function updateOrganization( /** * Delete an organization + * This should only be accessible to super-admins or during account cancellation flows */ export async function deleteOrganization(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - await pb.collection('organizations').delete(id) - return true - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.deleteOrganization' - ) - } + // This function should be restricted to super-admins only + // or be part of a special account cancellation flow + throw new SecurityError('This operation is restricted') } /** * Update organization subscription details + * This should only be called from Stripe webhooks, not directly by users */ export async function updateSubscription( id: string, @@ -142,12 +164,25 @@ export async function updateSubscription( priceId?: string } ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // This function should verify it's being called from a valid webhook + // For demo purposes, we'll implement a basic check + // In production, you'd add a webhook secret validation + + // We'll skip full security checks since this is called from webhooks + // but we still validate the organization exists + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Verify the organization exists + const organization = await pb.collection('organizations').getOne(id) + if (!organization) { + throw new Error('Organization not found') + } + return await pb.collection('organizations').update(id, subscriptionData) } catch (error) { return handlePocketBaseError( @@ -156,3 +191,45 @@ export async function updateSubscription( ) } } + +/** + * Get current organization settings for the authenticated user + */ +export async function getCurrentOrganizationSettings(): Promise { + try { + // Get the current authenticated user + const user = await validateCurrentUser() + + // Get their organization + const organizationId = user.organization + + // Fetch the organization with validated access + return await getOrganization(organizationId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getCurrentOrganizationSettings' + ) + } +} + +/** + * Check if current user is organization admin + */ +export async function isCurrentUserOrgAdmin(): Promise { + try { + // Get current user + const user = await validateCurrentUser() + + // Check admin status + return user.isAdmin || user.role === 'admin' + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return false + } +} diff --git a/src/app/actions/services/pocketbase/userService.ts b/src/app/actions/services/pocketbase/userService.ts index 0812d08..f8fddd0 100644 --- a/src/app/actions/services/pocketbase/userService.ts +++ b/src/app/actions/services/pocketbase/userService.ts @@ -1,28 +1,64 @@ 'use server' -import { getPocketBase, handlePocketBaseError } from './baseService' -import { ListOptions, ListResult, User } from './types' +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { ListOptions, ListResult, User } from '@/types/types_pocketbase' /** - * Get a single user by ID + * Get a single user by ID with security validation */ export async function getUser(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('users').getOne(id) } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } return handlePocketBaseError(error, 'UserService.getUser') } } /** - * Get a user by Clerk ID + * Get current authenticated user profile + */ +export async function getCurrentUser(): Promise { + try { + // This function automatically validates the current user + return await validateCurrentUser() + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getCurrentUser') + } +} + +/** + * Get a user by Clerk ID - typically used during authentication */ export async function getUserByClerkId(clerkId: string): Promise { + // This is primarily used during authentication flows where + // standard security checks aren't possible yet. + // However, requests should still come from server-side code only. const pb = await getPocketBase() if (!pb) { throw new Error('Failed to connect to PocketBase') @@ -36,111 +72,192 @@ export async function getUserByClerkId(clerkId: string): Promise { } /** - * Get users list with pagination + * Get users list with pagination and security checks */ export async function getUsersList( + organizationId: string, options: ListOptions = {} ): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('users').getList(page, perPage, rest) + // Security check - needs at least READ permission + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('users').getList(page, perPage, { + ...rest, + filter, + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.getUsersList') } } /** - * Get all users for an organization + * Get all users for an organization with security checks */ export async function getUsersByOrganization( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter return await pb.collection('users').getFullList({ filter: `organization="${organizationId}"`, sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.getUsersByOrganization') } } /** - * Create a new user + * Create a new user with security checks + * This is typically controlled access for admins only */ -export async function createUser(data: Partial): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - +export async function createUser( + organizationId: string, + data: Omit, 'organization'> +): Promise { try { - return await pb.collection('users').create(data) + // Security check - requires ADMIN permission to create users + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly + return await pb.collection('users').create({ + ...data, + organization: organizationId, // Force the correct organization ID + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.createUser') } } /** - * Update a user + * Update a user with security checks */ export async function updateUser( id: string, - data: Partial + data: Omit, 'organization' | 'id'> ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('users').update(id, data) + // Get current authenticated user + const currentUser = await validateCurrentUser() + + // Different permission checks based on who is being updated + if (id !== currentUser.id) { + // Updating someone else requires ADMIN permission + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) + } else { + // Users can update their own basic info + // But for role changes, they'd still need admin rights + if (data.role || data.isAdmin !== undefined) { + // If trying to change role or admin status, require admin permission + await validateOrganizationAccess( + currentUser.organization, + PermissionLevel.ADMIN + ) + } + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Security: never allow changing certain fields + const sanitizedData = { ...data } + delete (sanitizedData as any).organization // Don't allow org changes + delete sanitizedData.clerkId // Don't allow changing Clerk binding + + return await pb.collection('users').update(id, sanitizedData) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.updateUser') } } /** - * Delete a user + * Delete a user with admin permission check */ export async function deleteUser(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check - requires ADMIN permission for user deletion + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + await pb.collection('users').delete(id) return true } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.deleteUser') } } /** * Update user's last login time + * This is typically called during authentication flows */ export async function updateUserLastLogin(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Since this is called during authentication, + // we'll just verify the user exists rather than permissions + const user = await validateCurrentUser(id) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + return await pb.collection('users').update(id, { lastLogin: new Date().toISOString(), }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.updateUserLastLogin') } } @@ -149,18 +266,59 @@ export async function updateUserLastLogin(id: string): Promise { * Get the count of users in an organization */ export async function getUserCount(organizationId: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + const result = await pb.collection('users').getList(1, 1, { - filter: 'organization=' + `${organizationId}`, + filter: `organization="${organizationId}"`, skipTotal: false, }) + return result.totalItems } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'UserService.getUserCount') } } + +/** + * Search for users in the organization + */ +export async function searchUsers( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').getFullList({ + filter: pb.filter( + 'organization = {:orgId} && (name ~ {:query} || email ~ {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.searchUsers') + } +} From 5e456eb54d0614d66159dfec6e807084db61b338 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:15:34 +0200 Subject: [PATCH 04/73] feat(api): update equipment action result type - Change data type in EquipmentActionResult to use Equipment - Improve type safety for equipment management actions - Enhance validation error handling with clearer structure --- src/app/actions/equipment/manageEquipments.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/actions/equipment/manageEquipments.ts b/src/app/actions/equipment/manageEquipments.ts index f9a2347..e8c58f7 100644 --- a/src/app/actions/equipment/manageEquipments.ts +++ b/src/app/actions/equipment/manageEquipments.ts @@ -7,6 +7,7 @@ import { generateUniqueCode, } from '@/app/actions/services/pocketbase/equipmentService' import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' +import { Equipment } from '@/types/types_pocketbase' import { revalidatePath } from 'next/cache' import { z } from 'zod' @@ -27,7 +28,7 @@ type EquipmentFormData = z.infer export type EquipmentActionResult = { success: boolean message?: string - data?: any + data?: Equipment validationErrors?: Record } From 6ab0119c684ce0191f201cec315770119f5ec28c Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:32:15 +0200 Subject: [PATCH 05/73] feat(equipment): enhance tag handling and API consistency - Add function to convert tags array to JSON string for storage - Update equipment creation and update actions to use new tag conversion - Change API filter field names for consistency with updated data model - Refactor assignment service methods to align with new field naming conventions - Introduce utility functions for converting tags between formats - Adjust types in PocketBase interface to reflect changes in tag handling --- src/app/actions/equipment/manageEquipments.ts | 22 ++++- .../services/pocketbase/assignmentService.ts | 81 +++++++++++-------- .../services/pocketbase/equipmentService.ts | 47 +++++++---- src/lib/tagsUtils.ts | 42 ++++++++++ src/types/types_pocketbase.ts | 2 +- 5 files changed, 142 insertions(+), 52 deletions(-) create mode 100644 src/lib/tagsUtils.ts diff --git a/src/app/actions/equipment/manageEquipments.ts b/src/app/actions/equipment/manageEquipments.ts index e8c58f7..3837ad4 100644 --- a/src/app/actions/equipment/manageEquipments.ts +++ b/src/app/actions/equipment/manageEquipments.ts @@ -32,6 +32,14 @@ export type EquipmentActionResult = { validationErrors?: Record } +/** + * Convert tags array to string for PocketBase storage + */ +function convertTagsForStorage(tags?: string[]): string | null { + if (!tags || tags.length === 0) return null + return JSON.stringify(tags) +} + /** * Create a new equipment item */ @@ -48,8 +56,12 @@ export async function createEquipmentAction( // Create the equipment with security checks built into the service const newEquipment = await createEquipment(organizationId, { - ...validatedData, + acquisitionDate: validatedData.acquisitionDate || null, + name: validatedData.name, + notes: validatedData.notes || null, + parentEquipmentId: validatedData.parentEquipment || null, qrNfcCode, + tags: convertTagsForStorage(validatedData.tags), }) // Revalidate relevant paths to refresh data @@ -109,7 +121,13 @@ export async function updateEquipmentAction( const validatedData = equipmentSchema.parse(formData) // Update the equipment with security checks built into the service - const updatedEquipment = await updateEquipment(equipmentId, validatedData) + const updatedEquipment = await updateEquipment(equipmentId, { + acquisitionDate: validatedData.acquisitionDate || null, + name: validatedData.name, + notes: validatedData.notes || null, + parentEquipmentId: validatedData.parentEquipment || null, + tags: convertTagsForStorage(validatedData.tags), + }) // Revalidate relevant paths to refresh data revalidatePath('/dashboard/equipment') diff --git a/src/app/actions/services/pocketbase/assignmentService.ts b/src/app/actions/services/pocketbase/assignmentService.ts index 5453f86..c290e54 100644 --- a/src/app/actions/services/pocketbase/assignmentService.ts +++ b/src/app/actions/services/pocketbase/assignmentService.ts @@ -97,9 +97,9 @@ export async function getActiveAssignments( const now = new Date().toISOString() return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToUser,assignedToProject', + expand: 'equipmentId,assignedToUserId,assignedToProjectId', filter: pb.filter( - 'organization = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', + 'organizationId = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', { now, orgId: organizationId } ), sort: '-created', @@ -138,16 +138,17 @@ export async function getCurrentEquipmentAssignment( // Include organization check for extra security const assignments = await pb.collection('assignments').getList(1, 1, { - expand: 'equipment,assignedToUser,assignedToProject', + expand: 'equipmentId,assignedToUserId,assignedToProjectId', filter: pb.filter( - 'organization = {:orgId} && equipment = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', + 'organizationId = {:orgId} && equipmentId = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', { equipId: equipmentId, now, orgId: organizationId } ), sort: '-created', }) - // todo: fix type error - return assignments.items.length > 0 ? assignments.items[0] : null + return assignments.items.length > 0 + ? (assignments.items[0] as Assignment) + : null } catch (error) { if (error instanceof SecurityError) { throw error @@ -180,10 +181,10 @@ export async function getUserAssignments( // Include organization filter for security return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToProject', + expand: 'equipmentId,assignedToProjectId', filter: createOrganizationFilter( organizationId, - `assignedToUser="${userId}"` + `assignedToUserId="${userId}"` ), sort: '-created', }) @@ -216,10 +217,10 @@ export async function getProjectAssignments( // Include organization filter for security return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToUser', + expand: 'equipmentId,assignedToUserId', filter: createOrganizationFilter( organizationId, - `assignedToProject="${projectId}"` + `assignedToProjectId=${projectId}` ), sort: '-created', }) @@ -239,38 +240,43 @@ export async function getProjectAssignments( */ export async function createAssignment( organizationId: string, - data: Omit, 'organization'> + data: Pick< + Partial, + | 'equipmentId' + | 'assignedToUserId' + | 'assignedToProjectId' + | 'startDate' + | 'endDate' + | 'notes' + > ): Promise { try { // Security check - requires WRITE permission - const { user } = await validateOrganizationAccess( - organizationId, - PermissionLevel.WRITE - ) + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) // If equipment is provided, verify access to it - if (data.equipment) { + if (data.equipmentId) { await validateResourceAccess( ResourceType.EQUIPMENT, - data.equipment, + data.equipmentId, PermissionLevel.READ ) } // If assignedToUser is provided, verify access to that user - if (data.assignedToUser) { + if (data.assignedToUserId) { await validateResourceAccess( ResourceType.USER, - data.assignedToUser, + data.assignedToUserId, PermissionLevel.READ ) } // If assignedToProject is provided, verify access to that project - if (data.assignedToProject) { + if (data.assignedToProjectId) { await validateResourceAccess( ResourceType.PROJECT, - data.assignedToProject, + data.assignedToProjectId, PermissionLevel.READ ) } @@ -283,7 +289,7 @@ export async function createAssignment( // Ensure organization ID is set correctly return await pb.collection('assignments').create({ ...data, - organization: organizationId, // Force the correct organization ID + organizationId, // Force the correct organization ID }) } catch (error) { if (error instanceof SecurityError) { @@ -298,37 +304,45 @@ export async function createAssignment( */ export async function updateAssignment( id: string, - data: Omit, 'organization' | 'id'> + data: Pick< + Partial, + | 'equipmentId' + | 'assignedToUserId' + | 'assignedToProjectId' + | 'startDate' + | 'endDate' + | 'notes' + > ): Promise { try { // Security check - requires WRITE permission for the assignment - const { organizationId } = await validateResourceAccess( + await validateResourceAccess( ResourceType.ASSIGNMENT, id, PermissionLevel.WRITE ) // Additional validations for related resources - if (data.equipment) { + if (data.equipmentId) { await validateResourceAccess( ResourceType.EQUIPMENT, - data.equipment, + data.equipmentId, PermissionLevel.READ ) } - if (data.assignedToUser) { + if (data.assignedToUserId) { await validateResourceAccess( ResourceType.USER, - data.assignedToUser, + data.assignedToUserId, PermissionLevel.READ ) } - if (data.assignedToProject) { + if (data.assignedToProjectId) { await validateResourceAccess( ResourceType.PROJECT, - data.assignedToProject, + data.assignedToProjectId, PermissionLevel.READ ) } @@ -340,7 +354,8 @@ export async function updateAssignment( // Never allow changing the organization const sanitizedData = { ...data } - delete (sanitizedData as any).organization + // Use type assertion with more specific type + delete (sanitizedData as Record).organizationId return await pb.collection('assignments').update(id, sanitizedData) } catch (error) { @@ -427,10 +442,10 @@ export async function getEquipmentAssignmentHistory( // Include organization filter for security return await pb.collection('assignments').getFullList({ - expand: 'assignedToUser,assignedToProject', + expand: 'assignedToUserId,assignedToProjectId', filter: createOrganizationFilter( organizationId, - `equipment=${equipmentId}` + `equipmentId="${equipmentId}"` ), sort: '-startDate', }) diff --git a/src/app/actions/services/pocketbase/equipmentService.ts b/src/app/actions/services/pocketbase/equipmentService.ts index 3062ca2..32aec81 100644 --- a/src/app/actions/services/pocketbase/equipmentService.ts +++ b/src/app/actions/services/pocketbase/equipmentService.ts @@ -123,8 +123,8 @@ export async function getOrganizationEquipment( throw new Error('Failed to connect to PocketBase') } - // Apply organization filter - const filter = `organization="${organizationId}"` + // Apply organization filter - fixed field name to match interface + const filter = `organizationId=${organizationId}` return await pb.collection('equipment').getFullList({ filter, @@ -146,14 +146,19 @@ export async function getOrganizationEquipment( */ export async function createEquipment( organizationId: string, - data: Omit, 'organization'> + data: Pick< + Partial, + | 'name' + | 'qrNfcCode' + | 'tags' + | 'notes' + | 'acquisitionDate' + | 'parentEquipmentId' + > ): Promise { try { - // Security check - requires WRITE permission - const { user } = await validateOrganizationAccess( - organizationId, - PermissionLevel.WRITE - ) + // Security check - requires WRITE permission - removed unused user variable + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) const pb = await getPocketBase() if (!pb) { @@ -161,9 +166,10 @@ export async function createEquipment( } // Ensure organization ID is set and matches the authenticated user's org + // Fixed field name to match interface return await pb.collection('equipment').create({ ...data, - organization: organizationId, // Force the correct organization ID + organizationId, // Force the correct organization ID }) } catch (error) { if (error instanceof SecurityError) { @@ -178,7 +184,15 @@ export async function createEquipment( */ export async function updateEquipment( id: string, - data: Omit, 'organization' | 'id'> + data: Pick< + Partial, + | 'name' + | 'qrNfcCode' + | 'tags' + | 'notes' + | 'acquisitionDate' + | 'parentEquipmentId' + > ): Promise { try { // Security check - validates organization and requires WRITE permission @@ -195,7 +209,8 @@ export async function updateEquipment( // Never allow changing the organization const sanitizedData = { ...data } - delete sanitizedData.organization + // Fixed 'any' type and field name + delete (sanitizedData as Record).organizationId return await pb.collection('equipment').update(id, sanitizedData) } catch (error) { @@ -252,10 +267,10 @@ export async function getChildEquipment( throw new Error('Failed to connect to PocketBase') } - // Apply organization filter for security + // Apply organization filter for security - fixed field name const filter = createOrganizationFilter( organizationId, - `parentEquipment="${parentId}"` + `parentEquipmentId="${parentId}"` ) return await pb.collection('equipment').getFullList({ @@ -296,7 +311,7 @@ export async function getEquipmentCount( } const result = await pb.collection('equipment').getList(1, 1, { - filter: `organization=${organizationId}`, + filter: `organizationId="${organizationId}"`, // Fixed field name skipTotal: false, }) return result.totalItems @@ -326,10 +341,10 @@ export async function searchEquipment( return await pb.collection('equipment').getFullList({ filter: pb.filter( - 'organization = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', + 'organizationId = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', { orgId: organizationId, - query: query, + query, } ), sort: 'name', diff --git a/src/lib/tagsUtils.ts b/src/lib/tagsUtils.ts new file mode 100644 index 0000000..44a2f0e --- /dev/null +++ b/src/lib/tagsUtils.ts @@ -0,0 +1,42 @@ +/** + * Convert tags array to string format for PocketBase storage + * @param tags Array of tag strings + * @returns JSON string representation or null + */ +export function tagsToStorage(tags?: string[]): string | null { + if (!tags || tags.length === 0) return null + return JSON.stringify(tags) +} + +/** + * Convert tags from PocketBase storage format to array for UI + * @param tagsString JSON string or null from PocketBase + * @returns Array of tag strings + */ +export function tagsFromStorage(tagsString: string | null): string[] { + if (!tagsString) return [] + + try { + const parsed = JSON.parse(tagsString) + if (Array.isArray(parsed)) { + return parsed + } + // Handle case where it might be a comma-separated string + if (typeof parsed === 'string') { + return parsed + .split(',') + .map(tag => tag.trim()) + .filter(Boolean) + } + return [] + } catch (error) { + // If JSON parsing fails, try treating it as a comma-separated string + if (typeof tagsString === 'string') { + return tagsString + .split(',') + .map(tag => tag.trim()) + .filter(Boolean) + } + return [] + } +} diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index 53ce642..04e54c9 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -64,7 +64,7 @@ export interface Equipment extends BaseModel { organizationId: string // References Organization.id name: string | null qrNfcCode: string | null - tags: string | null // Consider parsing this as string[] + tags: string[] notes: string | null acquisitionDate: string | null // ISO date string parentEquipmentId: string | null // Self-reference From a64291987ee7d81e1d7e8a0471d7a231f985f86d Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:38:03 +0200 Subject: [PATCH 06/73] feat(api): enhance organization and project services - Fix template literal syntax for fetching organizations - Improve user organization access validation logic - Add function to retrieve user's organizations with pagination - Update current organization settings retrieval to handle multiple orgs - Refactor admin check to verify user belongs to specific organization - Standardize field names in project filters and creation methods - Ensure correct handling of organization ID across project-related functions --- .../pocketbase/organizationService.ts | 109 +++++++++++++++--- .../services/pocketbase/projectService.ts | 30 +++-- 2 files changed, 115 insertions(+), 24 deletions(-) diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts index fcfcac0..336892b 100644 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ b/src/app/actions/services/pocketbase/organizationService.ts @@ -7,8 +7,6 @@ import { import { validateCurrentUser, validateOrganizationAccess, - validateResourceAccess, - ResourceType, PermissionLevel, SecurityError, } from '@/app/actions/services/pocketbase/securityUtils' @@ -53,15 +51,31 @@ export async function getOrganizationByClerkId( throw new Error('Failed to connect to PocketBase') } + // Fixed the template literal syntax const organization = await pb .collection('organizations') - .getFirstListItem(`clerkId=`${clerkId}``) + .getFirstListItem(`clerkId=${clerkId}`) // After fetching, verify that the user belongs to this organization - if (user.organization !== organization.id) { + // The user can have multiple organizations, so we need to check if the requested org + // is in their list of organizations + if ( + !user.expand?.organizationId || + !Array.isArray(user.expand.organizationId) + ) { + throw new SecurityError('User has no associated organizations') + } + + // Check if the requested organization is in the user's list of organizations + const hasAccess = user.expand.organizationId.some( + org => org.id === organization.id + ) + + if (!hasAccess) { throw new SecurityError('User does not belong to this organization') } + // todo: fix type return organization } catch (error) { if (error instanceof SecurityError) { @@ -74,6 +88,52 @@ export async function getOrganizationByClerkId( } } +/** + * Get organizations list with pagination for the current user + */ +export async function getUserOrganizations(): Promise { + try { + const user = await validateCurrentUser() + + // If the user's organizations are already expanded, return them + if ( + user.expand?.organizationId && + Array.isArray(user.expand.organizationId) + ) { + return user.expand.organizationId + } + + // Otherwise, we need to fetch them + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Assuming there's a relation field in the users collection that points to organizations + // Fetch the user with expanded organizations + const userWithOrgs = await pb.collection('users').getOne(user.id, { + expand: 'organizationId', + }) + + if ( + userWithOrgs.expand?.organizationId && + Array.isArray(userWithOrgs.expand.organizationId) + ) { + return userWithOrgs.expand.organizationId + } + + return [] + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getUserOrganizations' + ) + } +} + /** * Get organizations list with pagination * This should only be accessible to super-admins, so we don't implement it @@ -194,17 +254,23 @@ export async function updateSubscription( /** * Get current organization settings for the authenticated user + * If the user belongs to multiple organizations, takes the first active one or prompts selection */ export async function getCurrentOrganizationSettings(): Promise { try { - // Get the current authenticated user - const user = await validateCurrentUser() + // Get all organizations for the current user + const userOrganizations = await getUserOrganizations() + + if (!userOrganizations.length) { + throw new SecurityError('User does not belong to any organization') + } - // Get their organization - const organizationId = user.organization + // For simplicity, we're returning the first organization + // In a real application, you might want to use the last selected org or prompt for selection + const firstOrgId = userOrganizations[0].id - // Fetch the organization with validated access - return await getOrganization(organizationId) + // Fetch full organization details with validated access + return await getOrganization(firstOrgId) } catch (error) { if (error instanceof SecurityError) { throw error @@ -217,15 +283,30 @@ export async function getCurrentOrganizationSettings(): Promise { } /** - * Check if current user is organization admin + * Check if current user is organization admin for a specific organization */ -export async function isCurrentUserOrgAdmin(): Promise { +export async function isCurrentUserOrgAdmin( + organizationId: string +): Promise { try { // Get current user const user = await validateCurrentUser() - // Check admin status - return user.isAdmin || user.role === 'admin' + // Check if user has admin role + const isAdmin = user.isAdmin || user.role === 'admin' + + // If they're not an admin by role, we need to check if they're an admin of this specific org + if (!isAdmin) { + // This would need additional checks in a real application + // For example, checking a userOrganizationRole table + return false + } + + // Verify they belong to this organization + const userOrgs = await getUserOrganizations() + const belongsToOrg = userOrgs.some(org => org.id === organizationId) + + return isAdmin && belongsToOrg } catch (error) { if (error instanceof SecurityError) { throw error diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts index 318ecb0..57625b0 100644 --- a/src/app/actions/services/pocketbase/projectService.ts +++ b/src/app/actions/services/pocketbase/projectService.ts @@ -89,8 +89,8 @@ export async function getOrganizationProjects( throw new Error('Failed to connect to PocketBase') } - // Apply organization filter - const filter = `organization="${organizationId}"` + // Apply organization filter - fixed field name + const filter = `organizationId="${organizationId}"` return await pb.collection('projects').getFullList({ filter, @@ -125,9 +125,10 @@ export async function getActiveProjects( const now = new Date().toISOString() + // Fixed field name in filter return await pb.collection('projects').getFullList({ filter: pb.filter( - 'organization = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', + 'organizationId = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', { now, orgId: organizationId } ), sort: 'name', @@ -145,7 +146,10 @@ export async function getActiveProjects( */ export async function createProject( organizationId: string, - data: Omit, 'organization'> + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > ): Promise { try { // Security check - requires WRITE permission @@ -156,10 +160,10 @@ export async function createProject( throw new Error('Failed to connect to PocketBase') } - // Ensure organization ID is set correctly + // Ensure organization ID is set correctly - fixed field name return await pb.collection('projects').create({ ...data, - organization: organizationId, // Force the correct organization ID + organizationId, // Force the correct organization ID }) } catch (error) { if (error instanceof SecurityError) { @@ -174,7 +178,10 @@ export async function createProject( */ export async function updateProject( id: string, - data: Omit, 'organization' | 'id'> + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > ): Promise { try { // Security check - requires WRITE permission @@ -191,7 +198,8 @@ export async function updateProject( // Never allow changing the organization const sanitizedData = { ...data } - delete (sanitizedData as any).organization + // Fixed 'any' type and field name + delete (sanitizedData as Record).organizationId return await pb.collection('projects').update(id, sanitizedData) } catch (error) { @@ -242,8 +250,9 @@ export async function getProjectCount(organizationId: string): Promise { throw new Error('Failed to connect to PocketBase') } + // Fixed field name const result = await pb.collection('projects').getList(1, 1, { - filter: `organization="${organizationId}"`, + filter: `organizationId=${organizationId}`, skipTotal: false, }) @@ -272,9 +281,10 @@ export async function searchProjects( throw new Error('Failed to connect to PocketBase') } + // Fixed field name in filter return await pb.collection('projects').getFullList({ filter: pb.filter( - 'organization = {:orgId} && (name ~ {:query} || address ~ {:query})', + 'organizationId = {:orgId} && (name ~ {:query} || address ~ {:query})', { orgId: organizationId, query, From d7b4ecef79c4e766ea1bdc95f301400642c29b6c Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:40:36 +0200 Subject: [PATCH 07/73] feat(api): update user organization handling - Change organization filter to use correct field name - Update createUser and updateUser functions to reflect new organizationId structure - Ensure proper security checks for users belonging to multiple organizations - Improve error handling for user not found scenarios - Fix filters in getUserCount and searchUsers functions for consistency --- .../services/pocketbase/userService.ts | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/src/app/actions/services/pocketbase/userService.ts b/src/app/actions/services/pocketbase/userService.ts index f8fddd0..9fbbd98 100644 --- a/src/app/actions/services/pocketbase/userService.ts +++ b/src/app/actions/services/pocketbase/userService.ts @@ -124,9 +124,10 @@ export async function getUsersByOrganization( throw new Error('Failed to connect to PocketBase') } - // Apply organization filter + // Apply organization filter with the correct field name + // Since users can belong to multiple organizations, we need to check expand.organizationId return await pb.collection('users').getFullList({ - filter: `organization="${organizationId}"`, + filter: `organizationId.organizationId="${organizationId}"`, sort: 'name', }) } catch (error) { @@ -143,7 +144,19 @@ export async function getUsersByOrganization( */ export async function createUser( organizationId: string, - data: Omit, 'organization'> + data: Pick< + Partial, + | 'name' + | 'email' + | 'emailVisibility' + | 'verified' + | 'avatar' + | 'phone' + | 'role' + | 'isAdmin' + | 'canLogin' + | 'clerkId' + > ): Promise { try { // Security check - requires ADMIN permission to create users @@ -154,10 +167,10 @@ export async function createUser( throw new Error('Failed to connect to PocketBase') } - // Ensure organization ID is set correctly + // Ensure organization ID is set correctly with the proper field name return await pb.collection('users').create({ ...data, - organization: organizationId, // Force the correct organization ID + organizationId, // Force the correct organization ID }) } catch (error) { if (error instanceof SecurityError) { @@ -172,7 +185,20 @@ export async function createUser( */ export async function updateUser( id: string, - data: Omit, 'organization' | 'id'> + data: Pick< + Partial, + | 'name' + | 'email' + | 'emailVisibility' + | 'verified' + | 'avatar' + | 'phone' + | 'role' + | 'isAdmin' + | 'canLogin' + | 'lastLogin' + | 'clerkId' + > ): Promise { try { // Get current authenticated user @@ -187,10 +213,16 @@ export async function updateUser( // But for role changes, they'd still need admin rights if (data.role || data.isAdmin !== undefined) { // If trying to change role or admin status, require admin permission - await validateOrganizationAccess( - currentUser.organization, - PermissionLevel.ADMIN - ) + // Get the user's organization ID - handling possible multiple organizations + const userOrgs = currentUser.expand?.organizationId + + if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { + throw new SecurityError('User does not belong to any organization') + } + + // Use the first organization for permission check + const primaryOrgId = userOrgs[0].id + await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) } } @@ -201,8 +233,11 @@ export async function updateUser( // Security: never allow changing certain fields const sanitizedData = { ...data } - delete (sanitizedData as any).organization // Don't allow org changes - delete sanitizedData.clerkId // Don't allow changing Clerk binding + // Don't allow org changes or clerk ID changes - use proper type assertion + delete (sanitizedData as Record).organizationId + if (sanitizedData['clerkId']) { + delete sanitizedData.clerkId + } return await pb.collection('users').update(id, sanitizedData) } catch (error) { @@ -246,6 +281,10 @@ export async function updateUserLastLogin(id: string): Promise { // we'll just verify the user exists rather than permissions const user = await validateCurrentUser(id) + if (!user) { + throw new SecurityError('User not found') + } + const pb = await getPocketBase() if (!pb) { throw new Error('Failed to connect to PocketBase') @@ -275,8 +314,9 @@ export async function getUserCount(organizationId: string): Promise { throw new Error('Failed to connect to PocketBase') } + // Fixed field name in the filter const result = await pb.collection('users').getList(1, 1, { - filter: `organization="${organizationId}"`, + filter: `organizationId.organizationId=${organizationId}`, skipTotal: false, }) @@ -305,9 +345,10 @@ export async function searchUsers( throw new Error('Failed to connect to PocketBase') } + // Fixed field name in the filter and handle multi-organization relationship return await pb.collection('users').getFullList({ filter: pb.filter( - 'organization = {:orgId} && (name ~ {:query} || email ~ {:query})', + 'organizationId.organizationId = {:orgId} && (name ~ {:query} || email ~ {:query})', { orgId: organizationId, query, From e9526f6120c1a89acc170904951e021b0c92e9ed Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:54:58 +0200 Subject: [PATCH 08/73] feat(docs): update documentation and add new files - Add diagram section to documentation - Include new large text file with project structure - Update .gitignore to exclude additional files and directories - Create output.txt for project representation details --- .gitignore | 14 +- docs-and-prompts/diagram-mermaid.md | 4 + large.txt | 29 + output.txt | 8299 +++++++++++++++++++++++++++ 4 files changed, 8341 insertions(+), 5 deletions(-) create mode 100644 large.txt create mode 100644 output.txt diff --git a/.gitignore b/.gitignore index 719fe3c..68b2bef 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,12 @@ template-radiant-tailwindui # export v0-context/ -.cursor/rules/rules-technique-prompt.mdc -.cursor/rules/rules-stack-technique.mdc -.cursor/rules/rules-cahier-des-charges.mdc -tsconfig.tsbuildinfo -bun.lock \ No newline at end of file +# .cursor/rules/rules-technique-prompt.mdc +# .cursor/rules/rules-stack-technique.mdc +# .cursor/rules/rules-cahier-des-charges.mdc +# tsconfig.tsbuildinfo +# bun.lock +# src/app/(marketing) +# src/app/(marketing)/marketing-components/ +# src/app/(marketing) +# /home/andycinquin/clonedrepo/for-tooling/src/app/(marketing) \ No newline at end of file diff --git a/docs-and-prompts/diagram-mermaid.md b/docs-and-prompts/diagram-mermaid.md index 785b93f..0515908 100644 --- a/docs-and-prompts/diagram-mermaid.md +++ b/docs-and-prompts/diagram-mermaid.md @@ -1,3 +1,6 @@ +# Diagram + +```text erDiagram Organization { string id PK @@ -91,3 +94,4 @@ Equipment }o--o{ Assignment : "is assigned via" Equipment }o--o{ Equipment : "parent/child" Project }o--o{ Assignment : includes +``` diff --git a/large.txt b/large.txt new file mode 100644 index 0000000..1fad7b1 --- /dev/null +++ b/large.txt @@ -0,0 +1,29 @@ +src/types/types_pocketbase.ts +src/components/ui/stepper.tsx +src/components/ui/sidebar.tsx +src/components/ui/sheet.tsx +src/components/magicui/confetti.tsx +src/components/app/app-sidebar.tsx +src/app/globals.css +src/app/actions/services/pocketbase/userService.ts +src/app/actions/services/pocketbase/securityUtils.ts +src/app/actions/services/pocketbase/projectService.ts +src/app/actions/services/pocketbase/organizationService.ts +src/app/actions/services/pocketbase/equipmentService.ts +src/app/actions/services/pocketbase/assignmentService.ts +src/app/actions/equipment/manageEquipments.ts +src/app/(application)/app/page.tsx +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/page.tsx +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep.tsx +docs-and-prompts/technique-prompt-system.md +docs-and-prompts/stack-technique.md +docs-and-prompts/cahier-des-charges.md +docs-and-prompts/market/tunnel-conversion.md +docs-and-prompts/market/strategie-marketing-honnete.md +docs-and-prompts/market/strategie-marketing-fortooling.md +docs-and-prompts/market/pages-techniques-parcours.md +docs-and-prompts/market/pages-support-conversion.md +docs-and-prompts/market/pages-seo-sectorielles.md +docs-and-prompts/market/pages-essentielles.md +docs-and-prompts/market/contenu-landing-page.md diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..c90e9c6 --- /dev/null +++ b/output.txt @@ -0,0 +1,8299 @@ +The following text represents a project with code. The structure of the text consists of sections beginning with ----, followed by a single line containing the file path and file name, and then a variable number of lines containing the file contents. The text representing the project ends when the symbols --END-- are encountered. Any further text beyond --END-- is meant to be interpreted as instructions using the aforementioned project as context. +---- +tsconfig.json +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + +---- +renovate.json +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "automergeType": "pr", + "automergeStrategy": "squash" + } + ] +} + +---- +prettier.config.js +module.exports = { + arrowParens: 'avoid', + bracketSpacing: true, + embeddedLanguageFormatting: 'auto', + endOfLine: 'auto', + htmlWhitespaceSensitivity: 'css', + insertPragma: false, + jsxSingleQuote: true, + plugins: ['prettier-plugin-tailwindcss'], + printWidth: 80, + proseWrap: 'preserve', + quoteProps: 'as-needed', + requirePragma: false, + semi: false, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + useTabs: true, + vueIndentScriptAndStyle: false, +} + +---- +postcss.config.mjs +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} + +export default config + +---- +package.json +{ + "name": "for-tooling", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "tsc": "npx tsc --noEmit --watch", + "prepare": "husky install" + }, + "dependencies": { + "@clerk/nextjs": "6.12.12", + "@eslint/js": "9.23.0", + "@headlessui/react": "2.2.0", + "@heroicons/react": "2.2.0", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "@types/canvas-confetti": "1.9.0", + "autoprefixer": "10.4.21", + "canvas-confetti": "1.9.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "dayjs": "1.11.13", + "framer-motion": "12.6.2", + "heroicons": "2.2.0", + "lucide-react": "0.485.0", + "next": "15.2.4", + "pocketbase": "0.25.2", + "postcss": "8.5.3", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-use-measure": "2.1.7", + "tailwind-merge": "3.0.2", + "tw-animate-css": "1.2.5", + "zod": "3.24.2", + "zustand": "5.0.3" + }, + "devDependencies": { + "@eslint/eslintrc": "3.3.1", + "@next/eslint-plugin-next": "15.2.4", + "@tailwindcss/postcss": "4.0.17", + "@types/node": "22.13.14", + "@types/react": "19.0.12", + "@types/react-dom": "19.0.4", + "@typescript-eslint/eslint-plugin": "8.28.0", + "@typescript-eslint/parser": "8.28.0", + "eslint": "9.23.0", + "eslint-config-next": "15.2.4", + "eslint-config-prettier": "10.1.1", + "eslint-plugin-perfectionist": "4.10.1", + "eslint-plugin-prettier": "5.2.5", + "eslint-plugin-react": "7.37.4", + "husky": "9.1.7", + "prettier": "3.5.3", + "prettier-plugin-tailwindcss": "0.6.11", + "tailwindcss": "4.0.17", + "tailwindcss-animate": "1.0.7", + "typescript": "5.8.2", + "typescript-eslint": "8.28.0" + } +} + +---- +next.config.ts +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + /* config options here */ + images: { + remotePatterns: [ + { + hostname: '**.andy-cinquin.fr', + protocol: 'https', + }, + { + hostname: '**.clerk.com', + protocol: 'https', + }, + ], + }, +} + +export default nextConfig + +---- +large.txt +src/types/types_pocketbase.ts +src/components/ui/stepper.tsx +src/components/ui/sidebar.tsx +src/components/ui/sheet.tsx +src/components/magicui/confetti.tsx +src/components/app/app-sidebar.tsx +src/app/globals.css +src/app/actions/services/pocketbase/userService.ts +src/app/actions/services/pocketbase/securityUtils.ts +src/app/actions/services/pocketbase/projectService.ts +src/app/actions/services/pocketbase/organizationService.ts +src/app/actions/services/pocketbase/equipmentService.ts +src/app/actions/services/pocketbase/assignmentService.ts +src/app/actions/equipment/manageEquipments.ts +src/app/(application)/app/page.tsx +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/page.tsx +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep.tsx +docs-and-prompts/technique-prompt-system.md +docs-and-prompts/stack-technique.md +docs-and-prompts/cahier-des-charges.md +docs-and-prompts/market/tunnel-conversion.md +docs-and-prompts/market/strategie-marketing-honnete.md +docs-and-prompts/market/strategie-marketing-fortooling.md +docs-and-prompts/market/pages-techniques-parcours.md +docs-and-prompts/market/pages-support-conversion.md +docs-and-prompts/market/pages-seo-sectorielles.md +docs-and-prompts/market/pages-essentielles.md +docs-and-prompts/market/contenu-landing-page.md + +---- +eslint.config.mjs +import { FlatCompat } from '@eslint/eslintrc' +import js from '@eslint/js' +import perfectionist from 'eslint-plugin-perfectionist' +import { defineConfig } from 'eslint/config' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const compat = new FlatCompat({ + allConfig: js.configs.all, + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}) + +export default defineConfig([ + { + extends: compat.extends('next/core-web-vitals', 'next/typescript'), + + plugins: { + perfectionist, + }, + + rules: { + 'no-console': [ + 'error', + { + allow: ['warn', 'error', 'info'], + }, + ], + + 'perfectionist/sort-enums': 'error', + 'perfectionist/sort-imports': ['error'], + 'perfectionist/sort-objects': 'error', + 'perfectionist/sort-variable-declarations': 'error', + }, + + settings: { + perfectionist: { + partitionByComment: false, + partitionByNewLine: false, + type: 'alphabetical', + }, + }, + }, +]) + +---- +components.json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} + +---- +README.md +# Fortooling + +-- +api : + +- https://api.fortooling.forhives.fr/_/ +- See our vaultwarden for password and credentials + +---- +LICENSE +MIT License + +Copyright (c) 2025 ForHives + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---- +src/middleware.ts +import { + clerkClient, + clerkMiddleware, + createRouteMatcher, +} from '@clerk/nextjs/server' +const isProtectedRoute = createRouteMatcher(['/app(.*)']) + +const isPublicRoute = createRouteMatcher([ + '/', + '/app(.*)', + '/pricing(.*)', + '/legals(.*)', + '/marketing-components(.*)', + '/sign-in(.*)', + '/sign-up(.*)', + '/api(.*)', + '/invitation(.*)', + '/onboarding(.*)', + '/create-organization(.*)', + '/waitlist(.*)', +]) + +const isAdminRoute = createRouteMatcher(['/admin(.*)']) + +export default clerkMiddleware(async (auth, req) => { + if (isPublicRoute(req)) { + return + } + + const authAwaited = await auth() + if (!authAwaited.userId) { + return Response.redirect(new URL('/sign-in', req.url)) + } + + if (isAdminRoute(req)) { + const userData = authAwaited.orgRole + if (userData !== 'admin') { + return Response.redirect(new URL('/', req.url)) + } + } + + if (!authAwaited.orgId) { + return Response.redirect(new URL('/onboarding', req.url)) + } + + const clerkClientInstance = await clerkClient() + const userMetadata = await clerkClientInstance.users.getUser( + authAwaited.userId + ) + + if ( + isProtectedRoute(req) && + !userMetadata?.publicMetadata?.hasCompletedOnboarding + ) { + return Response.redirect(new URL('/onboarding', req.url)) + } + + if (isProtectedRoute(req)) await auth.protect() +}) + +export const config = { + matcher: [ + // Skip Next.js internals and all static files, unless found in search params + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + // Always run for API routes + '/(api|trpc)(.*)', + ], +} + +---- +src/types/types_pocketbase.ts +/** + * Common fields for all record models + */ +export interface BaseModel { + id: string + created: string + updated: string + collectionId: string + collectionName: string +} + +/** + * Organization model + */ +export interface Organization extends BaseModel { + name: string + email: string | null + phone: string | null + address: string | null + settings: Record | null + + // Clerk integration fields + clerkId: string + + // Stripe related fields + stripeCustomerId?: string + subscriptionId?: string + subscriptionStatus?: string + priceId?: string + + // Expanded relations + expand?: Record +} + +/** + * User model (auth collection) + */ +export interface User extends BaseModel { + email: string + emailVisibility: boolean + verified: boolean + name: string | null + avatar?: string | null // File field + phone: string | null + role: string | null + isAdmin: boolean + canLogin: boolean + lastLogin?: string + + // Clerk integration field + clerkId?: string + + // Expanded relations + // the user can be part of multiple organizations + expand?: { + organizationId?: Organization[] + } +} + +/** + * Equipment model + */ +export interface Equipment extends BaseModel { + organizationId: string // References Organization.id + name: string | null + qrNfcCode: string | null + tags: string[] + notes: string | null + acquisitionDate: string | null // ISO date string + parentEquipmentId: string | null // Self-reference + + // Expanded relations + expand?: { + organizationId?: Organization + parentEquipmentId?: Equipment + } +} + +/** + * Project model + */ +export interface Project extends BaseModel { + name: string | null + address: string | null + notes: string | null + startDate: string | null // ISO date string + endDate: string | null // ISO date string + organizationId: string // References Organization.id + + // Expanded relations + expand?: { + organizationId?: Organization + } +} + +/** + * Assignment model + */ +export interface Assignment extends BaseModel { + organizationId: string // References Organization.id + equipmentId: string // References Equipment.id + assignedToUserId: string | null // References User.id + assignedToProjectId: string | null // References Project.id + startDate: string | null // ISO date string + endDate: string | null // ISO date string + notes: string | null + + // Expanded relations + expand?: { + organizationId?: Organization + equipmentId?: Equipment + assignedToUserId?: User + assignedToProjectId?: Project + } +} + +/** + * Images model (this will be used to store images for the blog etc) + */ +export interface Image extends BaseModel { + title: string | null + alt: string | null + caption: string | null + image: string | null + + // Expanded relations + expand?: Record +} + +/** + * Filter options for list operations + */ +export interface ListOptions { + filter?: string + sort?: string + expand?: string + fields?: string + skipTotal?: boolean + page?: number + perPage?: number + requestKey?: string | null +} + +/** + * Common result format for paginated lists + */ +export interface ListResult { + page: number + perPage: number + totalItems: number + totalPages: number + items: T[] +} + +---- +src/stores/onboarding-store.ts +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export type OnboardingStep = 1 | 2 | 3 | 4 | 5 + +interface OnboardingState { + currentStep: OnboardingStep + setCurrentStep: (step: OnboardingStep) => void + isLoading: boolean + setIsLoading: (loading: boolean) => void + resetOnboarding: () => void +} + +export const useOnboardingStore = create()( + persist( + set => ({ + currentStep: 1, + isLoading: false, + resetOnboarding: () => set({ currentStep: 1, isLoading: false }), + setCurrentStep: step => set({ currentStep: step }), + setIsLoading: loading => set({ isLoading: loading }), + }), + { + name: 'onboarding-state', + } + ) +) + +---- +src/lib/utils.ts +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +---- +src/lib/tagsUtils.ts +/** + * Convert tags array to string format for PocketBase storage + * @param tags Array of tag strings + * @returns JSON string representation or null + */ +export function tagsToStorage(tags?: string[]): string | null { + if (!tags || tags.length === 0) return null + return JSON.stringify(tags) +} + +/** + * Convert tags from PocketBase storage format to array for UI + * @param tagsString JSON string or null from PocketBase + * @returns Array of tag strings + */ +export function tagsFromStorage(tagsString: string | null): string[] { + if (!tagsString) return [] + + try { + const parsed = JSON.parse(tagsString) + if (Array.isArray(parsed)) { + return parsed + } + // Handle case where it might be a comma-separated string + if (typeof parsed === 'string') { + return parsed + .split(',') + .map(tag => tag.trim()) + .filter(Boolean) + } + return [] + } catch (error) { + // If JSON parsing fails, try treating it as a comma-separated string + if (typeof tagsString === 'string') { + return tagsString + .split(',') + .map(tag => tag.trim()) + .filter(Boolean) + } + return [] + } +} + +---- +src/hooks/use-mobile.ts +import * as React from 'react' + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) + + return !!isMobile +} + +---- +src/components/organization-sync.tsx +// src/components/organization-sync.tsx +'use client' +import { useOrganization } from '@clerk/nextjs' +import { useRouter, useParams } from 'next/navigation' +import { useEffect } from 'react' + +export function OrganizationSync() { + const { organization } = useOrganization() + const params = useParams() + const router = useRouter() + const orgId = params?.orgId as string | undefined + + useEffect(() => { + // If there's an active organization and it doesn't match the URL, update the URL + if (organization && orgId && organization.id !== orgId) { + router.replace(`/org/${organization.id}`) + } + + // If there's no active organization but we have an orgId in the URL, set it as active + if (!organization && orgId) { + // This would require additional logic to set the active organization + } + }, [organization, orgId, router]) + + return null +} + +---- +src/components/ui/tooltip.tsx +'use client' + +import { cn } from '@/lib/utils' +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import * as React from 'react' + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + children, + className, + sideOffset = 0, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } + +---- +src/components/ui/stepper.tsx +'use client' + +import { cn } from '@/lib/utils' +import { CheckIcon } from '@radix-ui/react-icons' +import { LoaderCircle } from 'lucide-react' +import * as React from 'react' +import { createContext, useContext } from 'react' + +// Types +type StepperContextValue = { + activeStep: number + setActiveStep: (step: number) => void + orientation: 'horizontal' | 'vertical' +} + +type StepItemContextValue = { + step: number + state: StepState + isDisabled: boolean + isLoading: boolean +} + +type StepState = 'active' | 'completed' | 'inactive' | 'loading' + +// Contexts +const StepperContext = createContext(undefined) +const StepItemContext = createContext( + undefined +) + +const useStepper = () => { + const context = useContext(StepperContext) + if (!context) { + throw new Error('useStepper must be used within a Stepper') + } + return context +} + +const useStepItem = () => { + const context = useContext(StepItemContext) + if (!context) { + throw new Error('useStepItem must be used within a StepperItem') + } + return context +} + +// Components +interface StepperProps extends React.HTMLAttributes { + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + orientation?: 'horizontal' | 'vertical' +} + +const Stepper = React.forwardRef( + ( + { + className, + defaultValue = 0, + onValueChange, + orientation = 'horizontal', + value, + ...props + }, + ref + ) => { + const [activeStep, setInternalStep] = React.useState(defaultValue) + + const setActiveStep = React.useCallback( + (step: number) => { + if (value === undefined) { + setInternalStep(step) + } + onValueChange?.(step) + }, + [value, onValueChange] + ) + + const currentStep = value ?? activeStep + + return ( + +
+ + ) + } +) +Stepper.displayName = 'Stepper' + +// StepperItem +interface StepperItemProps extends React.HTMLAttributes { + step: number + completed?: boolean + disabled?: boolean + loading?: boolean +} + +const StepperItem = React.forwardRef( + ( + { + children, + className, + completed = false, + disabled = false, + loading = false, + step, + ...props + }, + ref + ) => { + const { activeStep } = useStepper() + + const state: StepState = + completed || step < activeStep + ? 'completed' + : activeStep === step + ? 'active' + : 'inactive' + + const isLoading = loading && step === activeStep + + return ( + +
+ {children} +
+
+ ) + } +) +StepperItem.displayName = 'StepperItem' + +// StepperTrigger +interface StepperTriggerProps + extends React.ButtonHTMLAttributes { + asChild?: boolean +} + +const StepperTrigger = React.forwardRef( + ({ asChild = false, children, className, ...props }, ref) => { + const { setActiveStep } = useStepper() + const { isDisabled, step } = useStepItem() + + if (asChild) { + return
{children}
+ } + + return ( + + ) + } +) +StepperTrigger.displayName = 'StepperTrigger' + +// StepperIndicator +interface StepperIndicatorProps extends React.HTMLAttributes { + asChild?: boolean +} + +const StepperIndicator = React.forwardRef< + HTMLDivElement, + StepperIndicatorProps +>(({ asChild = false, children, className, ...props }, ref) => { + const { isLoading, state, step } = useStepItem() + + return ( +
+ {asChild ? ( + children + ) : ( + <> + + {step} + +
+ ) +}) +StepperIndicator.displayName = 'StepperIndicator' + +// StepperTitle +const StepperTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +StepperTitle.displayName = 'StepperTitle' + +// StepperDescription +const StepperDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +StepperDescription.displayName = 'StepperDescription' + +// StepperSeparator +const StepperSeparator = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +

+ ) +}) +StepperSeparator.displayName = 'StepperSeparator' + +export { + Stepper, + StepperDescription, + StepperIndicator, + StepperItem, + StepperSeparator, + StepperTitle, + StepperTrigger, +} + +---- +src/components/ui/spotlight-card.tsx +'use client' +import React, { useRef, useState } from 'react' + +interface Position { + x: number + y: number +} + +interface SpotlightCardProps extends React.PropsWithChildren { + className?: string + spotlightColor?: `rgba(${number}, ${number}, ${number}, ${number})` +} + +const SpotlightCard: React.FC = ({ + children, + className = '', + spotlightColor = 'rgba(255, 255, 255, 0.25)', +}) => { + const divRef = useRef(null) + const [isFocused, setIsFocused] = useState(false) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [opacity, setOpacity] = useState(0) + + const handleMouseMove: React.MouseEventHandler = e => { + if (!divRef.current || isFocused) return + + const rect = divRef.current.getBoundingClientRect() + setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }) + } + + const handleFocus = () => { + setIsFocused(true) + setOpacity(0.6) + } + + const handleBlur = () => { + setIsFocused(false) + setOpacity(0) + } + + const handleMouseEnter = () => { + setOpacity(0.6) + } + + const handleMouseLeave = () => { + setOpacity(0) + } + + return ( +
+
+ {children} +
+ ) +} + +export default SpotlightCard + +---- +src/components/ui/skeleton.tsx +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Skeleton } + +---- +src/components/ui/sidebar.tsx +'use client' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useIsMobile } from '@/hooks/use-mobile' +import { cn } from '@/lib/utils' +import { Slot } from '@radix-ui/react-slot' +import { VariantProps, cva } from 'class-variance-authority' +import { PanelLeftIcon } from 'lucide-react' +import * as React from 'react' + +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +type SidebarContextProps = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +function SidebarProvider({ + children, + className, + defaultOpen = true, + onOpenChange: setOpenProp, + open: openProp, + style, + ...props +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = React.useMemo( + () => ({ + isMobile, + open, + openMobile, + setOpen, + setOpenMobile, + state, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +function Sidebar({ + children, + className, + collapsible = 'offcanvas', + side = 'left', + variant = 'sidebar', + ...props +}: React.ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' +}) { + const { isMobile, openMobile, setOpenMobile, state } = useSidebar() + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +ConfettiButtonComponent.displayName = 'ConfettiButton' + +export const ConfettiButton = ConfettiButtonComponent + +---- +src/components/app/top-bar.tsx +'use client' +import { SignedIn } from '@clerk/nextjs' +import { Search } from 'lucide-react' +import { usePathname } from 'next/navigation' + +export function TopBar() { + const pathname = usePathname() + + // Function to generate page title based on pathname + const getPageTitle = () => { + if (pathname === '/app') return 'Dashboard' + + const paths = pathname?.split('/').filter(Boolean) || [] + if (paths.length === 0) return 'Dashboard' + + const lastPath = paths[paths.length - 1] + return lastPath.charAt(0).toUpperCase() + lastPath.slice(1) + } + + const pageTitle = getPageTitle() + + return ( +
+
+
+

{pageTitle}

+
+
+
+ + + + +
+
+ ) +} + +---- +src/components/app/container.tsx +import { clsx } from 'clsx' + +export function Container({ + children, + isAlternative = false, +}: { + children: React.ReactNode + isAlternative?: boolean +}) { + return ( +
+
+ {children} +
+
+ ) +} + +---- +src/components/app/app-sidebar.tsx +'use client' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, +} from '@/components/ui/sidebar' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + RedirectToSignIn, + SignedIn, + SignedOut, + UserButton, +} from '@clerk/nextjs' +import { + Construction, + Wrench, + User, + HardHat, + Scan, + ClipboardList, + Building, +} from 'lucide-react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +export function AppSidebar() { + const pathname = usePathname() + + return ( + +
+ + + +
+ +
+ +
+ Accueil +
+
+ + + + + + + + + + + + + Équipements + + + + + + + + + + + + Projets + + + + + + + + + + + + Utilisateurs + + + + + + + + + + + + Scanner + + + + + + + + + + + + Inventaire + + + + + + + + + + + + + + + + + + Organisation + + + + + + +
+ +
+ +
+
+ + + +
+
+
+ Profil +
+
+
+
+
+
+ ) +} + +---- +src/app/globals.css +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@keyframes move-x { + 0% { + transform: translateX(var(--move-x-from)); + } + 100% { + transform: translateX(var(--move-x-to)); + } +} + +---- +src/app/actions/services/pocketbase/userService.ts +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { ListOptions, ListResult, User } from '@/types/types_pocketbase' + +/** + * Get a single user by ID with security validation + */ +export async function getUser(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'UserService.getUser') + } +} + +/** + * Get current authenticated user profile + */ +export async function getCurrentUser(): Promise { + try { + // This function automatically validates the current user + return await validateCurrentUser() + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getCurrentUser') + } +} + +/** + * Get a user by Clerk ID - typically used during authentication + */ +export async function getUserByClerkId(clerkId: string): Promise { + // This is primarily used during authentication flows where + // standard security checks aren't possible yet. + // However, requests should still come from server-side code only. + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + try { + return await pb.collection('users').getFirstListItem(`clerkId="${clerkId}"`) + } catch (error) { + return handlePocketBaseError(error, 'UserService.getUserByClerkId') + } +} + +/** + * Get users list with pagination and security checks + */ +export async function getUsersList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check - needs at least READ permission + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('users').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getUsersList') + } +} + +/** + * Get all users for an organization with security checks + */ +export async function getUsersByOrganization( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter with the correct field name + // Since users can belong to multiple organizations, we need to check expand.organizationId + return await pb.collection('users').getFullList({ + filter: `organizationId.organizationId="${organizationId}"`, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getUsersByOrganization') + } +} + +/** + * Create a new user with security checks + * This is typically controlled access for admins only + */ +export async function createUser( + organizationId: string, + data: Pick< + Partial, + | 'name' + | 'email' + | 'emailVisibility' + | 'verified' + | 'avatar' + | 'phone' + | 'role' + | 'isAdmin' + | 'canLogin' + | 'clerkId' + > +): Promise { + try { + // Security check - requires ADMIN permission to create users + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly with the proper field name + return await pb.collection('users').create({ + ...data, + organizationId, // Force the correct organization ID + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.createUser') + } +} + +/** + * Update a user with security checks + */ +export async function updateUser( + id: string, + data: Pick< + Partial, + | 'name' + | 'email' + | 'emailVisibility' + | 'verified' + | 'avatar' + | 'phone' + | 'role' + | 'isAdmin' + | 'canLogin' + | 'lastLogin' + | 'clerkId' + > +): Promise { + try { + // Get current authenticated user + const currentUser = await validateCurrentUser() + + // Different permission checks based on who is being updated + if (id !== currentUser.id) { + // Updating someone else requires ADMIN permission + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) + } else { + // Users can update their own basic info + // But for role changes, they'd still need admin rights + if (data.role || data.isAdmin !== undefined) { + // If trying to change role or admin status, require admin permission + // Get the user's organization ID - handling possible multiple organizations + const userOrgs = currentUser.expand?.organizationId + + if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { + throw new SecurityError('User does not belong to any organization') + } + + // Use the first organization for permission check + const primaryOrgId = userOrgs[0].id + await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) + } + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Security: never allow changing certain fields + const sanitizedData = { ...data } + // Don't allow org changes or clerk ID changes - use proper type assertion + delete (sanitizedData as Record).organizationId + if (sanitizedData['clerkId']) { + delete sanitizedData.clerkId + } + + return await pb.collection('users').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.updateUser') + } +} + +/** + * Delete a user with admin permission check + */ +export async function deleteUser(id: string): Promise { + try { + // Security check - requires ADMIN permission for user deletion + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('users').delete(id) + return true + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.deleteUser') + } +} + +/** + * Update user's last login time + * This is typically called during authentication flows + */ +export async function updateUserLastLogin(id: string): Promise { + try { + // Since this is called during authentication, + // we'll just verify the user exists rather than permissions + const user = await validateCurrentUser(id) + + if (!user) { + throw new SecurityError('User not found') + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').update(id, { + lastLogin: new Date().toISOString(), + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.updateUserLastLogin') + } +} + +/** + * Get the count of users in an organization + */ +export async function getUserCount(organizationId: string): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed field name in the filter + const result = await pb.collection('users').getList(1, 1, { + filter: `organizationId.organizationId=${organizationId}`, + skipTotal: false, + }) + + return result.totalItems + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getUserCount') + } +} + +/** + * Search for users in the organization + */ +export async function searchUsers( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed field name in the filter and handle multi-organization relationship + return await pb.collection('users').getFullList({ + filter: pb.filter( + 'organizationId.organizationId = {:orgId} && (name ~ {:query} || email ~ {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.searchUsers') + } +} + +---- +src/app/actions/services/pocketbase/securityUtils.ts +'use server' + +import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +import { User } from '@/types/types_pocketbase' +import { auth } from '@clerk/nextjs/server' + +/** + * User permission levels + */ +export enum PermissionLevel { + ADMIN = 'admin', + READ = 'read', + WRITE = 'write', +} + +/** + * Resource types for permission checks + */ +export enum ResourceType { + ASSIGNMENT = 'assignment', + EQUIPMENT = 'equipment', + ORGANIZATION = 'organization', + PROJECT = 'project', + USER = 'user', +} + +/** + * Error thrown when security checks fail + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message) + this.name = 'SecurityError' + } +} + +/** + * Validates a user ID against the current authenticated user + * @param userId The user ID to validate + * @throws {SecurityError} If the user ID is invalid or unauthorized + */ +export async function validateCurrentUser(userId?: string): Promise { + // Get Clerk auth context + const { userId: clerkUserId } = await auth() + + if (!clerkUserId) { + throw new SecurityError('Unauthenticated access') + } + + const pb = await getPocketBase() + if (!pb) { + throw new SecurityError('Database connection error') + } + + try { + // Find the user by Clerk ID + const user = await pb + .collection('users') + .getFirstListItem(`clerkId=${clerkUserId}`) + + // If a specific user ID was provided, verify it matches the current user + if (userId && user.id !== userId) { + throw new SecurityError('Unauthorized access to user data') + } + + return user + } catch (error) { + console.error('User validation error:', error) + throw new SecurityError('Failed to validate user') + } +} + +/** + * Validates organizational access and permissions + * @param organizationId The organization ID to validate + * @param permission The required permission level + * @returns The validated user and organization + * @throws {SecurityError} If access is unauthorized + */ +export async function validateOrganizationAccess( + organizationId: string, + permission: PermissionLevel = PermissionLevel.READ +): Promise<{ user: User; organizationId: string }> { + // Get authenticated user + const user = await validateCurrentUser() + + // Check organization membership + if (user.expand?.organizationId !== organizationId) { + throw new SecurityError('Unauthorized access to organization data') + } + + // Check permission level + if ( + permission === PermissionLevel.ADMIN && + !user.isAdmin && + user.role !== 'admin' + ) { + throw new SecurityError('Insufficient permissions for this operation') + } + + if ( + permission === PermissionLevel.WRITE && + !user.isAdmin && + user.role !== 'admin' && + user.role !== 'manager' + ) { + throw new SecurityError('Insufficient permissions for this operation') + } + + return { organizationId, user } +} + +/** + * Validates resource access (equipment, project, assignment) + * @param resourceType The type of resource + * @param resourceId The resource ID + * @param permission The required permission level + * @returns The validated user and organization ID + * @throws {SecurityError} If access is unauthorized + */ +export async function validateResourceAccess( + resourceType: ResourceType, + resourceId: string, + permission: PermissionLevel = PermissionLevel.READ +): Promise<{ user: User; organizationId: string }> { + const pb = await getPocketBase() + if (!pb) { + throw new SecurityError('Database connection error') + } + + try { + // Fetch the resource to check organization membership + const resource = await pb.collection(resourceType).getOne(resourceId) + + // Now validate organization access with the required permission + return validateOrganizationAccess(resource.organization, permission) + } catch (error) { + console.error( + `Resource validation error (${resourceType}/${resourceId}):`, + error + ) + throw new SecurityError('Failed to validate resource access') + } +} + +/** + * Creates a secure organization filter + * Ensures that all queries include organization-level filtering + * @param organizationId The organization ID to filter by + * @param additionalFilter Optional additional filter expression + * @returns A complete filter string with organization filtering + */ +export function createOrganizationFilter( + organizationId: string, + additionalFilter?: string +): string { + const orgFilter = `organization="${organizationId}"` + + if (!additionalFilter) { + return orgFilter + } + + return `${orgFilter} && (${additionalFilter})` +} + +---- +src/app/actions/services/pocketbase/projectService.ts +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' + +/** + * Get a single project by ID with security validation + */ +export async function getProject(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.PROJECT, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('projects').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'ProjectService.getProject') + } +} + +/** + * Get projects list with pagination and security checks + */ +export async function getProjectsList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('projects').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.getProjectsList') + } +} + +/** + * Get all projects for an organization with security checks + */ +export async function getOrganizationProjects( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter - fixed field name + const filter = `organizationId="${organizationId}"` + + return await pb.collection('projects').getFullList({ + filter, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'ProjectService.getOrganizationProjects' + ) + } +} + +/** + * Get active projects with security checks + * (current date is between startDate and endDate or endDate is not set) + */ +export async function getActiveProjects( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() + + // Fixed field name in filter + return await pb.collection('projects').getFullList({ + filter: pb.filter( + 'organizationId = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', + { now, orgId: organizationId } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.getActiveProjects') + } +} + +/** + * Create a new project with security checks + */ +export async function createProject( + organizationId: string, + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > +): Promise { + try { + // Security check - requires WRITE permission + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly - fixed field name + return await pb.collection('projects').create({ + ...data, + organizationId, // Force the correct organization ID + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.createProject') + } +} + +/** + * Update a project with security checks + */ +export async function updateProject( + id: string, + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > +): Promise { + try { + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.PROJECT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + // Fixed 'any' type and field name + delete (sanitizedData as Record).organizationId + + return await pb.collection('projects').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.updateProject') + } +} + +/** + * Delete a project with security checks + */ +export async function deleteProject(id: string): Promise { + try { + // Security check - requires ADMIN permission for deletion + await validateResourceAccess( + ResourceType.PROJECT, + id, + PermissionLevel.ADMIN + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('projects').delete(id) + return true + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.deleteProject') + } +} + +/** + * Get project count for an organization with security checks + */ +export async function getProjectCount(organizationId: string): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed field name + const result = await pb.collection('projects').getList(1, 1, { + filter: `organizationId=${organizationId}`, + skipTotal: false, + }) + + return result.totalItems + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.getProjectCount') + } +} + +/** + * Search projects by name or address with security checks + */ +export async function searchProjects( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed field name in filter + return await pb.collection('projects').getFullList({ + filter: pb.filter( + 'organizationId = {:orgId} && (name ~ {:query} || address ~ {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.searchProjects') + } +} + +---- +src/app/actions/services/pocketbase/organizationService.ts +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' + +/** + * Get a single organization by ID with security validation + */ +export async function getOrganization(id: string): Promise { + try { + // Security check - validates user has access to this organization + await validateOrganizationAccess(id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('organizations').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'OrganizationService.getOrganization') + } +} + +/** + * Get an organization by Clerk ID with security validation + * This is primarily used during authentication + */ +export async function getOrganizationByClerkId( + clerkId: string +): Promise { + try { + // This endpoint is typically called during authentication + // We still validate the current user is authenticated + const user = await validateCurrentUser() + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed the template literal syntax + const organization = await pb + .collection('organizations') + .getFirstListItem(`clerkId=${clerkId}`) + + // After fetching, verify that the user belongs to this organization + // The user can have multiple organizations, so we need to check if the requested org + // is in their list of organizations + if ( + !user.expand?.organizationId || + !Array.isArray(user.expand.organizationId) + ) { + throw new SecurityError('User has no associated organizations') + } + + // Check if the requested organization is in the user's list of organizations + const hasAccess = user.expand.organizationId.some( + org => org.id === organization.id + ) + + if (!hasAccess) { + throw new SecurityError('User does not belong to this organization') + } + + // todo: fix type + return organization + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getOrganizationByClerkId' + ) + } +} + +/** + * Get organizations list with pagination for the current user + */ +export async function getUserOrganizations(): Promise { + try { + const user = await validateCurrentUser() + + // If the user's organizations are already expanded, return them + if ( + user.expand?.organizationId && + Array.isArray(user.expand.organizationId) + ) { + return user.expand.organizationId + } + + // Otherwise, we need to fetch them + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Assuming there's a relation field in the users collection that points to organizations + // Fetch the user with expanded organizations + const userWithOrgs = await pb.collection('users').getOne(user.id, { + expand: 'organizationId', + }) + + if ( + userWithOrgs.expand?.organizationId && + Array.isArray(userWithOrgs.expand.organizationId) + ) { + return userWithOrgs.expand.organizationId + } + + return [] + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getUserOrganizations' + ) + } +} + +/** + * Get organizations list with pagination + * This should only be accessible to super-admins, so we don't implement it + * in a regular multi-tenant app + */ +export async function getOrganizationsList( + options: ListOptions = {} +): Promise> { + // This function should be restricted to super-admins only + throw new SecurityError( + 'This operation is restricted to super administrators' + ) +} + +/** + * Create a new organization + * This should only be done during onboarding or by super-admins + */ +export async function createOrganization( + data: Partial +): Promise { + // For creating organizations, we typically handle this specially + // during onboarding with Clerk. This should not be exposed to regular users. + throw new SecurityError('This operation is restricted') +} + +/** + * Update an organization with security validation + */ +export async function updateOrganization( + id: string, + data: Partial +): Promise { + try { + // Security check - requires ADMIN permission for organization updates + await validateOrganizationAccess(id, PermissionLevel.ADMIN) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Sanitize sensitive fields + const sanitizedData = { ...data } + + // Never allow changing the clerkId - that's a special binding + delete sanitizedData.clerkId + + // Don't allow changing Stripe-related fields directly + // These should only be updated by the Stripe webhook + delete sanitizedData.stripeCustomerId + delete sanitizedData.subscriptionId + delete sanitizedData.subscriptionStatus + delete sanitizedData.priceId + + return await pb.collection('organizations').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.updateOrganization' + ) + } +} + +/** + * Delete an organization + * This should only be accessible to super-admins or during account cancellation flows + */ +export async function deleteOrganization(id: string): Promise { + // This function should be restricted to super-admins only + // or be part of a special account cancellation flow + throw new SecurityError('This operation is restricted') +} + +/** + * Update organization subscription details + * This should only be called from Stripe webhooks, not directly by users + */ +export async function updateSubscription( + id: string, + subscriptionData: { + stripeCustomerId?: string + subscriptionId?: string + subscriptionStatus?: string + priceId?: string + } +): Promise { + try { + // This function should verify it's being called from a valid webhook + // For demo purposes, we'll implement a basic check + // In production, you'd add a webhook secret validation + + // We'll skip full security checks since this is called from webhooks + // but we still validate the organization exists + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Verify the organization exists + const organization = await pb.collection('organizations').getOne(id) + if (!organization) { + throw new Error('Organization not found') + } + + return await pb.collection('organizations').update(id, subscriptionData) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService.updateSubscription' + ) + } +} + +/** + * Get current organization settings for the authenticated user + * If the user belongs to multiple organizations, takes the first active one or prompts selection + */ +export async function getCurrentOrganizationSettings(): Promise { + try { + // Get all organizations for the current user + const userOrganizations = await getUserOrganizations() + + if (!userOrganizations.length) { + throw new SecurityError('User does not belong to any organization') + } + + // For simplicity, we're returning the first organization + // In a real application, you might want to use the last selected org or prompt for selection + const firstOrgId = userOrganizations[0].id + + // Fetch full organization details with validated access + return await getOrganization(firstOrgId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getCurrentOrganizationSettings' + ) + } +} + +/** + * Check if current user is organization admin for a specific organization + */ +export async function isCurrentUserOrgAdmin( + organizationId: string +): Promise { + try { + // Get current user + const user = await validateCurrentUser() + + // Check if user has admin role + const isAdmin = user.isAdmin || user.role === 'admin' + + // If they're not an admin by role, we need to check if they're an admin of this specific org + if (!isAdmin) { + // This would need additional checks in a real application + // For example, checking a userOrganizationRole table + return false + } + + // Verify they belong to this organization + const userOrgs = await getUserOrganizations() + const belongsToOrg = userOrgs.some(org => org.id === organizationId) + + return isAdmin && belongsToOrg + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return false + } +} + +---- +src/app/actions/services/pocketbase/equipmentService.ts +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { Equipment, ListOptions, ListResult } from '@/types/types_pocketbase' + +/** + * Get a single equipment item by ID with security validation + */ +export async function getEquipment(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess( + ResourceType.EQUIPMENT, + id, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('equipment').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'EquipmentService.getEquipment') + } +} + +/** + * Get equipment by QR/NFC code with organization validation + */ +export async function getEquipmentByCode( + organizationId: string, + qrNfcCode: string +): Promise { + try { + // Security check - validates user belongs to this organization + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter for security + const filter = createOrganizationFilter( + organizationId, + `qrNfcCode="${qrNfcCode}"` + ) + return await pb.collection('equipment').getFirstListItem(filter) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.getEquipmentByCode') + } +} + +/** + * Get equipment list with pagination and security checks + */ +export async function getEquipmentList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('equipment').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.getEquipmentList') + } +} + +/** + * Get all equipment for an organization with security check + */ +export async function getOrganizationEquipment( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter - fixed field name to match interface + const filter = `organizationId=${organizationId}` + + return await pb.collection('equipment').getFullList({ + filter, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'EquipmentService.getOrganizationEquipment' + ) + } +} + +/** + * Create a new equipment item with permission check + */ +export async function createEquipment( + organizationId: string, + data: Pick< + Partial, + | 'name' + | 'qrNfcCode' + | 'tags' + | 'notes' + | 'acquisitionDate' + | 'parentEquipmentId' + > +): Promise { + try { + // Security check - requires WRITE permission - removed unused user variable + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set and matches the authenticated user's org + // Fixed field name to match interface + return await pb.collection('equipment').create({ + ...data, + organizationId, // Force the correct organization ID + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.createEquipment') + } +} + +/** + * Update an equipment item with permission and ownership checks + */ +export async function updateEquipment( + id: string, + data: Pick< + Partial, + | 'name' + | 'qrNfcCode' + | 'tags' + | 'notes' + | 'acquisitionDate' + | 'parentEquipmentId' + > +): Promise { + try { + // Security check - validates organization and requires WRITE permission + await validateResourceAccess( + ResourceType.EQUIPMENT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + // Fixed 'any' type and field name + delete (sanitizedData as Record).organizationId + + return await pb.collection('equipment').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.updateEquipment') + } +} + +/** + * Delete an equipment item with permission check + */ +export async function deleteEquipment(id: string): Promise { + try { + // Security check - requires ADMIN permission for deletion + await validateResourceAccess( + ResourceType.EQUIPMENT, + id, + PermissionLevel.ADMIN + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('equipment').delete(id) + return true + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.deleteEquipment') + } +} + +/** + * Get child equipment (items that have this equipment as parent) + */ +export async function getChildEquipment( + parentId: string +): Promise { + try { + // Security check - validates parent equipment access + const { organizationId } = await validateResourceAccess( + ResourceType.EQUIPMENT, + parentId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter for security - fixed field name + const filter = createOrganizationFilter( + organizationId, + `parentEquipmentId="${parentId}"` + ) + + return await pb.collection('equipment').getFullList({ + filter, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.getChildEquipment') + } +} + +/** + * Generate a unique QR/NFC code + */ +export async function generateUniqueCode(): Promise { + // Generate a random alphanumeric code + const prefix = 'EQ' + const randomPart = Math.random().toString(36).substring(2, 10).toUpperCase() + return `${prefix}-${randomPart}` +} + +/** + * Get equipment count for an organization + */ +export async function getEquipmentCount( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const result = await pb.collection('equipment').getList(1, 1, { + filter: `organizationId="${organizationId}"`, // Fixed field name + skipTotal: false, + }) + return result.totalItems + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.getEquipmentCount') + } +} + +/** + * Search equipment by name or tag within organization + */ +export async function searchEquipment( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('equipment').getFullList({ + filter: pb.filter( + 'organizationId = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'EquipmentService.searchEquipment') + } +} + +---- +src/app/actions/services/pocketbase/baseService.ts +import 'server-only' +import PocketBase from 'pocketbase' + +// Singleton pattern for PocketBase instance +let instance: PocketBase | null = null + +/** + * Initialize and authenticate with PocketBase + * Uses server-side authentication with an admin token + * + * @returns {Promise} Authenticated PocketBase instance or null if authentication fails + */ +export const getPocketBase = async (): Promise => { + // Return existing instance if valid + if (instance?.authStore?.isValid) { + return instance + } + + // Get credentials from environment variables + const token = process.env.PB_USER_TOKEN + const url = process.env.PB_SERVER_URL + + if (!token || !url) { + console.error('Missing PocketBase credentials in environment variables') + return null + } + + // Create new PocketBase instance + instance = new PocketBase(url) + instance.authStore.save(token, null) + instance.autoCancellation(false) + + return instance +} + +/** + * Error handler for PocketBase operations + * @param error The caught error + * @param context Optional context information for better error reporting + */ +export const handlePocketBaseError = ( + error: unknown, + context?: string +): never => { + const contextMsg = context ? ` [${context}]` : '' + console.error(`PocketBase error${contextMsg}:`, error) + + if (error instanceof Error) { + throw new Error( + `PocketBase operation failed${contextMsg}: ${error.message}` + ) + } + + throw new Error(`Unknown PocketBase error${contextMsg}`) +} + +---- +src/app/actions/services/pocketbase/assignmentService.ts +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, + ResourceType, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { Assignment, ListOptions, ListResult } from '@/types/types_pocketbase' + +/** + * Get a single assignment by ID with security validation + */ +export async function getAssignment(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('assignments').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'AssignmentService.getAssignment') + } +} + +/** + * Get assignments list with pagination and security checks + */ +export async function getAssignmentsList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('assignments').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.getAssignmentsList') + } +} + +/** + * Get active assignments for an organization with security checks + * Active assignments have startDate ≤ current date and no endDate or endDate ≥ current date + */ +export async function getActiveAssignments( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() + + return await pb.collection('assignments').getFullList({ + expand: 'equipmentId,assignedToUserId,assignedToProjectId', + filter: pb.filter( + 'organizationId = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', + { now, orgId: organizationId } + ), + sort: '-created', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getActiveAssignments' + ) + } +} + +/** + * Get current assignment for a specific equipment with security checks + */ +export async function getCurrentEquipmentAssignment( + equipmentId: string +): Promise { + try { + // Security check - validates access to the equipment + const { organizationId } = await validateResourceAccess( + ResourceType.EQUIPMENT, + equipmentId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() + + // Include organization check for extra security + const assignments = await pb.collection('assignments').getList(1, 1, { + expand: 'equipmentId,assignedToUserId,assignedToProjectId', + filter: pb.filter( + 'organizationId = {:orgId} && equipmentId = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', + { equipId: equipmentId, now, orgId: organizationId } + ), + sort: '-created', + }) + + return assignments.items.length > 0 + ? (assignments.items[0] as Assignment) + : null + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getCurrentEquipmentAssignment' + ) + } +} + +/** + * Get assignments for a user with security checks + */ +export async function getUserAssignments( + userId: string +): Promise { + try { + // Security check - validates access to the user + const { organizationId } = await validateResourceAccess( + ResourceType.USER, + userId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Include organization filter for security + return await pb.collection('assignments').getFullList({ + expand: 'equipmentId,assignedToProjectId', + filter: createOrganizationFilter( + organizationId, + `assignedToUserId="${userId}"` + ), + sort: '-created', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.getUserAssignments') + } +} + +/** + * Get assignments for a project with security checks + */ +export async function getProjectAssignments( + projectId: string +): Promise { + try { + // Security check - validates access to the project + const { organizationId } = await validateResourceAccess( + ResourceType.PROJECT, + projectId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Include organization filter for security + return await pb.collection('assignments').getFullList({ + expand: 'equipmentId,assignedToUserId', + filter: createOrganizationFilter( + organizationId, + `assignedToProjectId=${projectId}` + ), + sort: '-created', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getProjectAssignments' + ) + } +} + +/** + * Create a new assignment with security checks + */ +export async function createAssignment( + organizationId: string, + data: Pick< + Partial, + | 'equipmentId' + | 'assignedToUserId' + | 'assignedToProjectId' + | 'startDate' + | 'endDate' + | 'notes' + > +): Promise { + try { + // Security check - requires WRITE permission + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) + + // If equipment is provided, verify access to it + if (data.equipmentId) { + await validateResourceAccess( + ResourceType.EQUIPMENT, + data.equipmentId, + PermissionLevel.READ + ) + } + + // If assignedToUser is provided, verify access to that user + if (data.assignedToUserId) { + await validateResourceAccess( + ResourceType.USER, + data.assignedToUserId, + PermissionLevel.READ + ) + } + + // If assignedToProject is provided, verify access to that project + if (data.assignedToProjectId) { + await validateResourceAccess( + ResourceType.PROJECT, + data.assignedToProjectId, + PermissionLevel.READ + ) + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly + return await pb.collection('assignments').create({ + ...data, + organizationId, // Force the correct organization ID + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.createAssignment') + } +} + +/** + * Update an assignment with security checks + */ +export async function updateAssignment( + id: string, + data: Pick< + Partial, + | 'equipmentId' + | 'assignedToUserId' + | 'assignedToProjectId' + | 'startDate' + | 'endDate' + | 'notes' + > +): Promise { + try { + // Security check - requires WRITE permission for the assignment + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.WRITE + ) + + // Additional validations for related resources + if (data.equipmentId) { + await validateResourceAccess( + ResourceType.EQUIPMENT, + data.equipmentId, + PermissionLevel.READ + ) + } + + if (data.assignedToUserId) { + await validateResourceAccess( + ResourceType.USER, + data.assignedToUserId, + PermissionLevel.READ + ) + } + + if (data.assignedToProjectId) { + await validateResourceAccess( + ResourceType.PROJECT, + data.assignedToProjectId, + PermissionLevel.READ + ) + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + // Use type assertion with more specific type + delete (sanitizedData as Record).organizationId + + return await pb.collection('assignments').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.updateAssignment') + } +} + +/** + * Delete an assignment with security checks + */ +export async function deleteAssignment(id: string): Promise { + try { + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('assignments').delete(id) + return true + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.deleteAssignment') + } +} + +/** + * Complete an assignment by setting its end date to now with security checks + */ +export async function completeAssignment(id: string): Promise { + try { + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.ASSIGNMENT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('assignments').update(id, { + endDate: new Date().toISOString(), + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AssignmentService.completeAssignment') + } +} + +/** + * Get assignment history for an equipment with security checks + */ +export async function getEquipmentAssignmentHistory( + equipmentId: string +): Promise { + try { + // Security check - validates access to the equipment + const { organizationId } = await validateResourceAccess( + ResourceType.EQUIPMENT, + equipmentId, + PermissionLevel.READ + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Include organization filter for security + return await pb.collection('assignments').getFullList({ + expand: 'assignedToUserId,assignedToProjectId', + filter: createOrganizationFilter( + organizationId, + `equipmentId="${equipmentId}"` + ), + sort: '-startDate', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AssignmentService.getEquipmentAssignmentHistory' + ) + } +} + +---- +src/app/actions/equipment/manageEquipments.ts +'use server' + +import { + createEquipment, + updateEquipment, + deleteEquipment, + generateUniqueCode, +} from '@/app/actions/services/pocketbase/equipmentService' +import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' +import { Equipment } from '@/types/types_pocketbase' +import { revalidatePath } from 'next/cache' +import { z } from 'zod' + +// Define validation schema for equipment data +const equipmentSchema = z.object({ + acquisitionDate: z.string().optional(), + name: z.string().min(2, 'Name must be at least 2 characters'), + notes: z.string().optional(), + parentEquipment: z.string().optional(), + tags: z.array(z.string()).optional(), +}) + +type EquipmentFormData = z.infer + +/** + * Result type for all equipment actions + */ +export type EquipmentActionResult = { + success: boolean + message?: string + data?: Equipment + validationErrors?: Record +} + +/** + * Convert tags array to string for PocketBase storage + */ +function convertTagsForStorage(tags?: string[]): string | null { + if (!tags || tags.length === 0) return null + return JSON.stringify(tags) +} + +/** + * Create a new equipment item + */ +export async function createEquipmentAction( + organizationId: string, + formData: EquipmentFormData +): Promise { + try { + // Validate input data + const validatedData = equipmentSchema.parse(formData) + + // Generate unique code for the equipment + const qrNfcCode = await generateUniqueCode() + + // Create the equipment with security checks built into the service + const newEquipment = await createEquipment(organizationId, { + acquisitionDate: validatedData.acquisitionDate || null, + name: validatedData.name, + notes: validatedData.notes || null, + parentEquipmentId: validatedData.parentEquipment || null, + qrNfcCode, + tags: convertTagsForStorage(validatedData.tags), + }) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + + return { + data: newEquipment, + message: 'Equipment created successfully', + success: true, + } + } catch (error) { + // Handle validation errors + if (error instanceof z.ZodError) { + const validationErrors = error.errors.reduce( + (acc, curr) => { + const key = curr.path.join('.') + acc[key] = curr.message + return acc + }, + {} as Record + ) + + return { + message: 'Validation failed', + success: false, + validationErrors, + } + } + + // Handle security errors + if (error instanceof SecurityError) { + return { + message: error.message, + success: false, + } + } + + // Handle other errors + console.error('Error creating equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +/** + * Update an existing equipment item + */ +export async function updateEquipmentAction( + equipmentId: string, + formData: EquipmentFormData +): Promise { + try { + // Validate input data + const validatedData = equipmentSchema.parse(formData) + + // Update the equipment with security checks built into the service + const updatedEquipment = await updateEquipment(equipmentId, { + acquisitionDate: validatedData.acquisitionDate || null, + name: validatedData.name, + notes: validatedData.notes || null, + parentEquipmentId: validatedData.parentEquipment || null, + tags: convertTagsForStorage(validatedData.tags), + }) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + revalidatePath(`/dashboard/equipment/${equipmentId}`) + + return { + data: updatedEquipment, + message: 'Equipment updated successfully', + success: true, + } + } catch (error) { + // Handle validation errors + if (error instanceof z.ZodError) { + const validationErrors = error.errors.reduce( + (acc, curr) => { + const key = curr.path.join('.') + acc[key] = curr.message + return acc + }, + {} as Record + ) + + return { + message: 'Validation failed', + success: false, + validationErrors, + } + } + + // Handle security errors + if (error instanceof SecurityError) { + return { + message: error.message, + success: false, + } + } + + // Handle other errors + console.error('Error updating equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +/** + * Delete an equipment item + */ +export async function deleteEquipmentAction( + equipmentId: string +): Promise { + try { + // Delete the equipment with security checks built into the service + await deleteEquipment(equipmentId) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + + return { + message: 'Equipment deleted successfully', + success: true, + } + } catch (error) { + // Handle security errors + if (error instanceof SecurityError) { + return { + message: error.message, + success: false, + } + } + + // Handle other errors + console.error('Error deleting equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +---- +src/app/(application)/app/page.tsx +import { CardDescription, CardTitle } from '@/components/ui/card' +import SpotlightCard from '@/components/ui/spotlight-card' +import { auth, currentUser } from '@clerk/nextjs/server' +import { + Construction, + Wrench, + User, + Building, + Scan, + ClipboardList, +} from 'lucide-react' +import Link from 'next/link' +import { redirect } from 'next/navigation' + +export default async function Dashboard() { + const { orgId, userId } = await auth() + + if (!userId || !orgId) { + redirect('/onboarding') + } + + const user = await currentUser() + + const quickLinks = [ + { + bgColor: 'bg-blue-100', + color: 'text-blue-600', + description: 'Gérer et suivre tous les équipements et outils', + href: '/app/equipments', + icon: Wrench, + spotlightColor: + 'rgba(59, 130, 246, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Équipements', + }, + { + bgColor: 'bg-amber-100', + color: 'text-amber-600', + description: 'Gérer les projets, chantiers et emplacements', + href: '/app/projects', + icon: Construction, + spotlightColor: + 'rgba(245, 158, 11, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Projets', + }, + { + bgColor: 'bg-green-100', + color: 'text-green-600', + description: 'Gérer les utilisateurs et permissions', + href: '/app/users', + icon: User, + spotlightColor: + 'rgba(34, 197, 94, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Utilisateurs', + }, + { + bgColor: 'bg-purple-100', + color: 'text-purple-600', + description: 'Scanner et localiser des équipements', + href: '/app/scan', + icon: Scan, + spotlightColor: + 'rgba(168, 85, 247, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Scanner', + }, + { + bgColor: 'bg-red-100', + color: 'text-red-600', + description: 'Rapports et inventaire complet', + href: '/app/inventory', + icon: ClipboardList, + spotlightColor: + 'rgba(239, 68, 68, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Inventaire', + }, + { + bgColor: 'bg-indigo-100', + color: 'text-indigo-600', + description: 'Paramètres et configuration', + href: '/organizations', + icon: Building, + spotlightColor: + 'rgba(99, 102, 241, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Organisation', + }, + ] + + return ( +
+

+ Bonjour {user?.firstName} ! +

+
+ {quickLinks.map(link => ( + + +
+
+ +
+ +
+ + {link.title} + + + {link.description} + +
+
+
+ + ))} +
+
+ ) +} + +---- +src/app/(application)/app/layout.tsx +import type { Metadata } from 'next' +import type React from 'react' + +import { AppSidebar } from '@/components/app/app-sidebar' +import { TopBar } from '@/components/app/top-bar' +import '@/app/globals.css' +import { SidebarProvider } from '@/components/ui/sidebar' +import { + ClerkProvider, + RedirectToSignIn, + SignedIn, + SignedOut, +} from '@clerk/nextjs' + +export const metadata: Metadata = { + title: { + default: 'ForTooling', + template: '%s - ForTooling', + }, +} + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + +
+ + +
+ +
+
+ {children} +
+
+
+
+
+
+ + + +
+ + + ) +} + +---- +src/app/(application)/app/actions/user.ts +'use server' + +import { auth, clerkClient } from '@clerk/nextjs/server' + +/** + * Marks the user's onboarding as complete by setting metadata + * This allows the application to know that the user has completed the onboarding process + * and shouldn't be redirected to the onboarding page again + */ +export async function markOnboardingComplete(): Promise { + const { userId } = await auth() + + if (!userId) { + throw new Error('Authentication required') + } + + try { + // Update the user's public metadata + // hasCompletedOnboarding=true indicates the user has finished onboarding + // onboardingCompletedAt stores the date when onboarding was completed + const clerkClientInstance = await clerkClient() + await clerkClientInstance.users.updateUserMetadata(userId, { + publicMetadata: { + hasCompletedOnboarding: true, + onboardingCompletedAt: new Date().toISOString(), + }, + }) + + return true + } catch (error) { + console.error('Error updating user metadata:', error) + throw new Error('Failed to complete onboarding') + } +} + +---- +src/app/(application)/(clerk)/layout.tsx +import '@/app/globals.css' + +import type { Metadata } from 'next' +import type React from 'react' + +import { AppSidebar } from '@/components/app/app-sidebar' +import { TopBar } from '@/components/app/top-bar' +import { SidebarProvider } from '@/components/ui/sidebar' +import { ClerkProvider } from '@clerk/nextjs' + +export const metadata: Metadata = { + title: { + default: "ForTooling - Gestion de l'outillage", + template: '%s - ForTooling', + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + + + +
+ + +
+ +
+
{children}
+
+
+
+
+ + +
+ ) +} + +---- +src/app/(application)/(clerk)/waitlist/[[...waitlist]]/page.tsx +import { Container } from '@/components/app/container' +import { Waitlist } from '@clerk/nextjs' + +export default function WaitlistPage() { + return ( + + + + ) +} + +---- +src/app/(application)/(clerk)/sign-up/[[...sign-up]]/page.tsx +import { Container } from '@/components/app/container' +import { SignUp } from '@clerk/nextjs' + +export default function SignUpPage() { + return ( + + + + ) +} + +---- +src/app/(application)/(clerk)/sign-in/[[...sign-in]]/page.tsx +import { Container } from '@/components/app/container' +import { SignIn } from '@clerk/nextjs' + +export default function SignInPage() { + return ( + + + + ) +} + +---- +src/app/(application)/(clerk)/organizations/page.tsx +import { Container } from '@/components/app/container' +import { OrganizationList } from '@clerk/nextjs' + +export default function OrganizationsPage() { + return ( + + + + ) +} + +---- +src/app/(application)/(clerk)/organization-profile/[[...organization-profile]]/page.tsx +import { Container } from '@/components/app/container' +import { OrganizationProfile } from '@clerk/nextjs' + +export default function OrganizationProfilePage() { + return ( + + + + ) +} + +---- +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/page.tsx +'use client' + +import { CompletionStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep' +import { FeaturesStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/FeaturesStep' +import { OrganizationStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep' +import { ProfileStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/ProfileStep' +import { WelcomeStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/WelcomeStep' +import { markOnboardingComplete } from '@/app/(application)/app/actions/user' +import { Container } from '@/components/app/container' +import { Button } from '@/components/ui/button' +import { + Stepper, + StepperItem, + StepperTrigger, + StepperIndicator, + StepperSeparator, + StepperTitle, + StepperDescription, +} from '@/components/ui/stepper' +import { OnboardingStep, useOnboardingStore } from '@/stores/onboarding-store' +import { + useUser, + SignedIn, + SignedOut, + RedirectToSignIn, + useOrganization, +} from '@clerk/nextjs' +import { + ArrowLeft, + ArrowRight, + Building, + CheckCircle2, + Info, + User, + Laptop, +} from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState, useEffect } from 'react' + +// Onboarding steps data +const steps = [ + { + description: 'Bienvenue sur ForTooling', + icon: , + id: 1, + title: 'Bienvenue', + }, + { + description: 'Découvrez les fonctionnalités clés', + icon: , + id: 2, + title: 'Fonctionnalités', + }, + { + description: 'Configurez votre organisation', + icon: , + id: 3, + title: 'Organisation', + }, + { + description: 'Complétez votre profil', + icon: , + id: 4, + title: 'Profil', + }, + { + description: 'Vous êtes prêt à commencer', + icon: , + id: 5, + title: 'Prêt !', + }, +] + +export default function OnboardingPage() { + const { isLoaded, isSignedIn, user } = useUser() + const router = useRouter() + const { organization } = useOrganization() + + // Use the Zustand store + const { currentStep, isLoading, setCurrentStep, setIsLoading } = + useOnboardingStore() + + // Step content components stored in an array + const [stepContents, setStepContents] = useState([]) + + useEffect(() => { + // Check if user has completed onboarding + if ( + isLoaded && + isSignedIn && + user?.publicMetadata?.hasCompletedOnboarding + ) { + router.push('/app') + } + + // Initialize step contents + setStepContents([ + , + , + , + , + , + ]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, isSignedIn, user, router, isLoading, organization]) + + const goToNextStep = () => { + // Check if we're on the organization step (index 2) and block if no organization + if (currentStep === 3 && !organization) { + return // Block progression if no organization + } + + if (currentStep < 5) { + setCurrentStep((currentStep + 1) as OnboardingStep) + } + } + + const goToPreviousStep = () => { + if (currentStep > 1) { + setCurrentStep((currentStep - 1) as OnboardingStep) + } + } + + async function completeOnboarding() { + setIsLoading(true) + try { + await markOnboardingComplete() + router.push('/app') + } catch (error) { + console.error('Failed to complete onboarding:', error) + } finally { + setIsLoading(false) + } + } + + if (!isLoaded) { + return ( +
+
Chargement...
+
+ ) + } + + return ( + <> + + +
+
+

+ Bienvenue sur votre plateforme de gestion d'équipements +

+

+ Suivez ces quelques étapes pour configurer votre compte et + commencer à utiliser ForTooling +

+
+ +
+ + {steps.map((step, index) => ( + step.id} + disabled={currentStep < step.id} + loading={isLoading && currentStep === step.id} + className='[&:not(:last-child)]:flex-1' + > + + currentStep >= step.id && + setCurrentStep(step.id as OnboardingStep) + } + > + {step.icon} +
+ {step.title} + + {step.description} + +
+
+ {index < steps.length - 1 && } +
+ ))} +
+
+ +
+ {stepContents[currentStep - 1]} +
+ +
+ + + {currentStep < 5 ? ( + + ) : ( + + )} +
+
+
+
+ + + + + ) +} + +---- +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/WelcomeStep.tsx +'use client' +import Image from 'next/image' + +export function WelcomeStep() { + return ( +
+
+
+ ForTooling Logo +
+
+

+ Bienvenue sur ForTooling +

+

+ Notre solution vous aide à suivre, attribuer et maintenir votre parc + d'équipements de manière simple
+ et efficace grâce aux technologies NFC et QR code. +

+
+ À jamais les casse-têtes de la gestion de votre parc d'équipements. +
+
+ ) +} + +---- +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/ProfileStep.tsx +'use client' +import { UserProfile } from '@clerk/nextjs' + +export function ProfileStep() { + return ( +
+

+ Complétez votre profil +

+

+ Ajoutez quelques informations pour personnaliser votre expérience et + faciliter la collaboration +

+
+ +
+
+ ) +} + +---- +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx +import { Button } from '@/components/ui/button' +import { Organization } from '@clerk/nextjs/server' +import { Building, User, Info, CheckCircle2 } from 'lucide-react' +import Image from 'next/image' +import { useRouter } from 'next/navigation' + +export function OrganizationStep({ + hasOrganization, + organization, +}: { + hasOrganization: boolean + organization: Organization +}) { + const router = useRouter() + + return ( +
+

+ Configurez votre organisation +

+

+ Vous devez créer ou rejoindre une organisation pour continuer. Cela + permettra de gérer les équipements et utilisateurs de votre entreprise. +

+ + {hasOrganization ? ( +
+
+ +
+

+ Organisation configurée avec succès! +

+

+ Vous pouvez continuer vers l'étape suivante. +

+
+ ForTooling Logo + {organization?.imageUrl && ( + <> + x + Organization Logo + + )} +
+
+ ) : ( + <> +
+ + +
+
+
+

+ Note importante +

+

+ Vous devez créer ou rejoindre une organisation avant de pouvoir + continuer. Cliquez sur l'un des boutons ci-dessus, puis + revenez à cette page. +

+
+
+ + )} +
+ ) +} + +---- +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/FeaturesStep.tsx +import { ClipboardList, Construction, Scan, Wrench } from 'lucide-react' + +export function FeaturesStep() { + return ( +
+

+ Découvrez nos fonctionnalités clés +

+
+
+
+ +

Suivi d'équipements

+
+

+ Localisez et suivez tous vos équipements en temps réel avec la + technologie NFC/QR +

+
+
+
+ +

Attribution aux projets

+
+

+ Affectez facilement des équipements aux utilisateurs et aux projets +

+
+
+
+ +

Scan rapide

+
+

+ Scannez les équipements en quelques secondes pour obtenir leur + statut et les gérer +

+
+
+
+ +

Rapports détaillés

+
+

+ Générez des analyses détaillées sur l'utilisation de votre parc + matériel +

+
+
+
+ ) +} + +---- +src/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep.tsx +'use client' + +import { Button } from '@/components/ui/button' +import confetti from 'canvas-confetti' +import { CheckCircle2, ThumbsUp, Rocket, CircleCheck } from 'lucide-react' +import { useEffect, useRef } from 'react' + +export function CompletionStep({ + isLoading, + onComplete, +}: { + onComplete: () => void + isLoading: boolean +}) { + const confettiTriggered = useRef(false) + + useEffect(() => { + // Trigger confetti animation when component loads + // But only once (using useRef for tracking) + if (!confettiTriggered.current) { + triggerConfetti() + confettiTriggered.current = true + } + }, []) + + const triggerConfetti = () => { + const end = Date.now() + 3 * 1000 // 3 seconds + const colors = ['#a786ff', '#fd8bbc', '#eca184', '#f8deb1'] + + // Side cannons animation (left and right) + const frame = () => { + if (Date.now() > end) return + + // Left side + confetti({ + angle: 60, + colors: colors, + origin: { x: 0, y: 0.5 }, + particleCount: 2, + spread: 55, + startVelocity: 60, + }) + + // Right side + confetti({ + angle: 120, + colors: colors, + origin: { x: 1, y: 0.5 }, + particleCount: 2, + spread: 55, + startVelocity: 60, + }) + + requestAnimationFrame(frame) + } + + frame() + + // Add a central burst at the beginning + confetti({ + origin: { y: 0.6 }, + particleCount: 100, + spread: 70, + }) + } + + return ( +
+
+
+ +
+
+ +

Félicitations ! 🎉

+

+ Vous êtes prêt à utiliser ForTooling +

+ +
+

+ Votre compte est maintenant configuré. Vous pouvez commencer à + utiliser ForTooling pour optimiser la gestion de votre parc + d'équipements. +

+
+ +
+
+ +

Gestion simplifiée

+
+
+ +

Performance optimisée

+
+
+ +

Expérience optimale

+
+
+ +
+

+ Notre équipe est disponible pour vous aider si vous avez des + questions. N'hésitez pas à nous contacter à{' '} + contact@fortooling.com +

+
+ +
+ +
+
+ ) +} + +---- +src/app/(application)/(clerk)/create-organization/[[...create-organization]]/page.tsx +'use client' + +import { Container } from '@/components/app/container' +import { CreateOrganization } from '@clerk/nextjs' + +export default function CreateOrganizationPage() { + return ( + + + + ) +} + +---- +docs-and-prompts/technique-prompt-system.md +# Prompt Système pour Assistant de Développement SaaS - Plateforme de Gestion d'Équipements NFC/QR + +## 🎯 Contexte du Projet + +Tu es un assistant de développement expert spécialisé dans la création d'une plateforme SaaS de gestion d'équipements avec tracking NFC/QR. Ce système permet aux entreprises de suivre, attribuer et maintenir leur parc d'équipements via une interface moderne et des fonctionnalités avancées de scanning et de reporting. + +## 📋 Directives Générales + +- **Langue**: Toujours coder et commenter en anglais +- **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques +- **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown +- **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code +- **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée +- **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité + +## 🏗️ Stack Technique à Respecter + +### Frontend + +- **Framework**: Next.js 15+, React 19+ +- **Styling**: Tailwind CSS 4+, shadcn/ui +- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 +- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) +- **Forms**: Tan Stack Form + Zod pour la validation +- **Animations**: Framer Motion, Rive pour les animations complexes +- **UI**: Composants shadcn/ui, icônes Lucide React +- **Mobile**: next-pwa, WebNFC API, QR code fallback + +### Backend + +- **API**: Next.js Server Actions avec middleware de protection centralisé +- **Validation**: Zod pour la validation des données +- **ORM**: Prisma avec PostgreSQL +- **Authentification**: Clerk 6+ +- **Paiements**: Stripe +- **Recherche**: Algolia +- **Stockage**: Cloudflare R2 +- **Emails**: Resend +- **SMS**: Twilio +- **Temps réel**: Socket.io +- **Tâches asynchrones**: Temporal.io + +### DevOps & Sécurité + +- **Déploiement**: Coolify, Docker +- **CI/CD**: GitHub Actions +- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip +- **Analytics**: Umami +- **Sécurité API**: Rate limiting, CORS, Helmet + +## 11. Schéma / visualisation + +Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. +Si il y a besoin de schémas, il faut les les créer avec [Mermaid](https://mermaid-js.github.io/) et suivre les bonnes pratiques de ce langage. + +## 🖋️ Conventions de Code & Documentation + +### Structuration du Code + +- Architecture modulaire et maintenable +- Séparation claire des préoccupations (SoC) +- DRY (Don't Repeat Yourself) et SOLID principles +- Pattern par fonctionnalité plutôt que par type technique +- Centralisation des vérifications de sécurité et d'autorisation + +### Style de Code + +- **TypeScript**: Types stricts et exhaustifs +- **React**: Composants fonctionnels avec hooks +- **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) +- **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types +- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement + +### Documentation + +- **JSDoc** pour toutes les fonctions, hooks, et types complexes: + +```typescript +/** + * Fetches equipment data based on provided filters + * @param {EquipmentFilters} filters - The filters to apply to the query + * @param {QueryOptions} options - Optional query parameters + * @returns {Promise} Array of equipment matching filters + * @throws {ApiError} When the API request fails + */ +``` + +- **Commentaires de code**: Explique le "pourquoi", pas le "quoi" +- Ajoute des logs explicatifs aux endroits clés + +### Tests + +- Tests unitaires avec Vitest +- Tests end-to-end avec Playwright +- Privilégier les tests pour la logique métier critique + +## 📐 Structure de Projet Attendue + +``` +src/ +├── app/ # Next.js App Router +│ ├── (auth)/ # Routes authentifiées +│ ├── (marketing)/ # Routes publiques (landing) +│ └── api/ # Routes API REST si nécessaire +├── components/ # Composants React partagés +│ ├── ui/ # Composants UI de base (shadcn) +│ └── [feature]/ # Composants spécifiques aux fonctionnalités +├── lib/ # Code utilitaire partagé +├── server/ # Code serveur +│ ├── actions/ # Next.js Server Actions protégées +│ │ └── middleware.ts # Wrapper de protection HOF +│ ├── db/ # Prisma et utilitaires DB +│ └── services/ # Logique métier +├── stores/ # Stores Zustand +├── styles/ # Styles globaux Tailwind +└── types/ # Types TypeScript partagés +``` + +## 🤝 Collaboration Attendue + +- **Proactivité**: Anticipe les besoins et problèmes potentiels +- **Pédagogie**: Explique les concepts complexes et les choix d'architecture +- **Adaptabilité**: Ajuste-toi à mes besoins et préférences au fur et à mesure +- **Progressivité**: Commence par les fondamentaux puis avance vers des implémentations plus complexes +- **Optimisations**: Suggère des améliorations mais priorise la lisibilité et la maintenabilité + +## 🚨 Anti-patterns à Éviter + +- Ne pas utiliser de classes React (préférer les composants fonctionnels) +- Éviter les any/unknown en TypeScript si possible +- Ne pas réinventer ce qui existe déjà dans les bibliothèques choisies +- Éviter les dépendances inutiles ou redondantes +- Ne pas mélanger les styles (préférer Tailwind) +- Éviter d'exposer des données sensibles dans le frontend +- Ne pas dupliquer la logique d'authentification et de validation +- Éviter de créer des Server Actions sans utiliser le middleware de protection + +## 🔄 Processus de Travail + +1. Comprends d'abord mon besoin ou problème +2. Propose une approche structurée avec les technologies appropriées +3. Implémente en expliquant les choix techniques +4. Suggère des améliorations ou alternatives si pertinent +5. Offre des conseils pour les tests et la maintenance + +Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. + +---- +docs-and-prompts/stack-technique.md +# Stack Technique Finale - Plateforme SaaS de Gestion d'Équipements NFC/QR + +## 1. Vue d'ensemble + +Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les technologies modernes du web pour offrir une solution robuste, performante et évolutive. L'architecture est conçue pour être hautement optimisée, sécurisée et facile à maintenir. + +## 2. Frontend + +### Framework & UI + +- **Next.js 15+** - Framework React avec App Router et Server Components +- **React 19+** - Bibliothèque UI pour construire des interfaces interactives +- **Tailwind CSS 4+** - Framework CSS utility-first pour le styling +- **shadcn/ui** - Composants UI réutilisables basés sur Radix UI +- **Lucide React** - Bibliothèque d'icônes SVG +- **Framer Motion** - Animations et transitions fluides +- **Rive** - Animations complexes et interactives + +### Gestion d'état client + +- **Zustand** - Gestion d'état global légère et simple + - Utilisé pour éviter le prop drilling + - Stockage des préférences utilisateur, thèmes, filtres + - État partagé entre composants distants + +### PWA & Mobile + +- **next-pwa** - Transforme l'application en Progressive Web App +- **WebNFC API** - Accès aux fonctionnalités NFC pour les appareils compatibles +- **QR Code fallback** - Solution alternative pour les appareils sans NFC + +### Qualité & Tests + +- **TypeScript** - Typage statique pour une meilleure qualité de code +- **ESLint/Prettier** - Linting et formatage de code +- **Vitest** - Tests unitaires rapides +- **Playwright** - Tests end-to-end + +## 3. Backend & API + +### API & Validation + +- **Next.js Server Actions** - Actions serveur typées et sécurisées + - Pattern de protection centralisé (HOF withProtection) + - Isolation multi-tenant intégrée +- **Zod** - Validation de schémas pour les données d'entrée +- **Tan stack Form** - Gestion de formulaires avec validation côté client + +### Backend + +- **Pockebase** - Backend as a service + +### Sécurité API + +- **Rate limiting** - Protection contre les abus +- **CORS** - Sécurité pour les requêtes cross-origin +- **Helmet** - Sécurisation des headers HTTP + +## 4. Services & Intégrations + +### Authentification & Paiements + +- **Clerk 6+** - Authentification complète et gestion des utilisateurs +- **Stripe** - Traitement des paiements et gestion des abonnements + +### Recherche & Stockage + +- **Algolia** - Recherche rapide et pertinente +- **Cloudflare R2** - Stockage d'objets compatible S3 + +### Communication & Notifications + +- **Resend** - Service d'emails transactionnels +- **Twilio** - SMS et notifications mobiles +- **Socket.io** - Communication temps réel pour le monitoring + +### Fonctionnalités spécifiques + +- **OpenStreetMap + Leaflet.js** - Cartographie et géolocalisation +- **React-PDF** - Génération de rapports PDF +- **SheetJS** - Export de données en format Excel +- **Temporal.io** - Orchestration de workflows et tâches asynchrones + +## 5. Infrastructure & DevOps + +### Déploiement & CI/CD + +- **Coolify** - Plateforme self-hosted pour le déploiement +- **Docker** - Conteneurisation des services +- **GitHub Actions** - Automatisation CI/CD + +### Monitoring & Observabilité + +- **Prometheus + Grafana** - Collecte et visualisation de métriques +- **Loki** - Agrégation et exploration de logs +- **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) +- **Umami** - Analytics respectueux de la vie privée + +### Sauvegarde & Restauration + +- **pgbackrest** - Solution de backup robuste pour PostgreSQL +- **pg_dump automatisé** - Sauvegardes programmées + +## 6. Architecture multi-tenant + +- Architecture à schéma unique avec discrimination par tenant_id +- Isolation des données par organisation au niveau des Server Actions +- Middleware de protection centralisé pour les vérifications d'accès +- Optimisation des requêtes grâce aux index sur tenant_id + +## 7. Intégration NFC/QR + +- Approche hybride WebNFC + QR Code +- Points de scan fixes (entrées/sorties) +- Options pour scanners Bluetooth dans les zones de forte utilisation + +## 8. Optimisations & Performance + +- **SEO** - Optimisation pour la partie publique (landing) + - Screaming Frog pour l'audit + - Lighthouse pour les bonnes pratiques +- **Web Vitals** - Suivi continu des métriques de performance +- **Unlighthouse/IBM checker** - Outils d'analyse supplémentaires + +## 9. Documentation + +- **Swagger/OpenAPI** - Documentation d'API auto-générée +- **Docusaurus** - Documentation utilisateur et technique + +## 10. Structure du projet + +``` +src/ +├── app/ # Next.js App Router +│ ├── (auth)/ # Routes authentifiées +│ ├── (marketing)/ # Routes publiques (landing) +│ └── api/ # Routes API REST si nécessaire +├── components/ # Composants React partagés +│ ├── ui/ # Composants UI de base (shadcn) +│ └── [feature]/ # Composants spécifiques aux fonctionnalités +├── lib/ # Code utilitaire partagé +├── server/ # Code serveur +│ ├── actions/ # Next.js Server Actions protégées +│ │ └── middleware.ts # Wrapper de protection HOF +│ ├── db/ # Prisma et utilitaires DB +│ └── services/ # Logique métier +├── stores/ # Stores Zustand +├── styles/ # Styles globaux Tailwind +└── types/ # Types TypeScript partagés +``` + +---- +docs-and-prompts/diagram-mermaid.md +# Diagram + +```text +erDiagram +Organization { + string id PK + string name + string email + string phone + string address + json settings + string clerkId + string stripeCustomerId + string subscriptionId + string subscriptionStatus + string priceId + date created + date updated +} + +User { + string id PK + string name + string email + string phone + string role + boolean isAdmin + boolean canLogin + string lastLogin + file avatar + boolean verified + boolean emailVisibility + string clerkId + date created + date updated +} + +Equipment { + string id PK + string organizationId FK + string name + string qrNfcCode + string tags + editor notes + date acquisitionDate + string parentEquipmentId FK + date created + date updated +} + +Project { + string id PK + string organizationId FK + string name + string address + editor notes + date startDate + date endDate + date created + date updated +} + +Assignment { + string id PK + string organizationId FK + string equipmentId FK + string assignedToUserId FK + string assignedToProjectId FK + date startDate + date endDate + editor notes + date created + date updated +} + +Image { + string id PK + string title + string alt + string caption + file image + date created + date updated +} + +Organization ||--o{ User : has +Organization ||--o{ Equipment : owns +Organization ||--o{ Project : manages +Organization ||--o{ Assignment : oversees + +User }o--o{ Assignment : "is assigned to" + +Equipment }o--o{ Assignment : "is assigned via" +Equipment }o--o{ Equipment : "parent/child" + +Project }o--o{ Assignment : includes +``` + +---- +docs-and-prompts/cahier-des-charges.md +## 1. Contexte et problématique générale + +### 1.1 Problématique adressée + +De nombreuses entreprises possèdent et gèrent un parc d'équipements qu'elles doivent suivre, attribuer et entretenir. Ces équipements peuvent représenter plusieurs dizaines à centaines d'articles différents (outils, matériel technique, appareils spécialisés, etc.). + +Les systèmes traditionnels de gestion présentent des lacunes importantes : + +- Suivi manuel chronophage et source d'erreurs +- Difficulté à localiser rapidement les équipements +- Absence d'historique fiable des mouvements et utilisations +- Complexité pour gérer les attributions +- Manque de visibilité globale sur l'état du parc +- Coût très elevé pour des balises gps précies (e.g hilti etc) +- Impossibilité d'appliquer ça sur des éléments autres + +## 2. Objectifs de la plateforme SaaS + +Développer une plateforme SaaS de gestion d'équipements qui permettra de : + +- Centraliser l'inventaire complet du parc matériel +- Suivre la localisation de chaque équipement en temps réel grâce à des étiquettes nfc/qr +- Gérer l'attribution des équipements aux utilisateurs et aux projets/emplacements +- Automatiser la détection des entrées/sorties d'équipements via des points de scan +- Conserver l'historique de tous les mouvements et utilisations +- Fournir des analyses et statistiques d'utilisation avancées +- Offrir une solution adaptable à différents secteurs d'activité + +## 3. Besoins fonctionnels détaillés + +### 3.1 Gestion multi-organisations + +- Support de plusieurs organisations clientes avec isolation complète des données +- Paramétrage par organisation (terminologie, champs personnalisés, flux de travail) +- Gestion des rôles et permissions par organisation + +### 3.2 Gestion des équipements + +- Inventaire complet avec informations détaillées : + - Référence unique et code NFC/qr associé + - Nom et description + - Date d'acquisition et valeur + - État et niveau d'usure + - Spécifications techniques (type, marque, modèle, etc.) + - Catégorie de rattachement + - Champs personnalisables selon le secteur d'activité +- Création, modification et suppression d'équipements +- Association d'un équipement à une catégorie spécifique +- Support pour documentation technique, photos et fichiers associés +- Gestion des maintenances préventives et curatives + +### 3.3 Suivi automatisé par NFC // ou SCAN QR Code + +- Intégration avec des étiquettes nfc/qr à faible coût / ou équivalent +- Points de scan aux entrées/sorties des zones de stockage +- Scan mobile via smartphones/tablettes pour vérification terrain +- Détection automatique des mouvements d'équipements +- Alertes en cas de sortie non autorisée + - mail + - sms + - alerte perso +- Cartographie des dernières localisations connues + +### 3.4 Gestion des affectations + +- Attribution d'équipements à : + - Un utilisateur/employé + - Un projet/chantier + - Un emplacement physique +- Enregistrement des dates de début et fin d'affectation +- Affectation groupée de plusieurs équipements simultanément +- Workflows d'approbation configurables +- Historique complet des affectations + +### 3.5 Gestion des utilisateurs + +- Enregistrement des informations sur les utilisateurs : + - Profil complet (nom, prénom, contact, etc.) + - Rôle et permissions dans le système + - Département/équipe de rattachement +- Suivi des équipements attribués à chaque utilisateur +- Gestion des accès par niveau de permission + +### 3.6 Gestion des projets/emplacements/chantiers + +- Structure flexible adaptable selon les besoins : + - Projets temporaires avec dates de début/fin + - Emplacements physiques permanents + - Zones géographiques +- Hiérarchisation possible (bâtiment > étage > pièce) +- Géolocalisation et cartographie +- Suivi des équipements affectés + +### 3.7 Catégorisation des équipements + +- Système de catégories et sous-catégories multiniveau +- Attributs spécifiques par catégorie d'équipement +- Système de préfixage automatique des références +- Organisation logique adaptée au secteur d'activité + +### 3.8 Analyses et statistiques avancées + +- Dashboard personnalisable avec indicateurs clés +- Rapports sur les taux d'utilisation des équipements +- Analyses prédictives pour planification des besoins +- Alertes sur équipements sous-utilisés ou sur-utilisés +- Statistiques par utilisateur, projet, catégorie et équipement +- Rapports exportables dans différents formats + +### 3.9 Intégration et API + +- API REST complète pour intégration avec d'autres systèmes +- Intégration possible avec des ERP, GMAO, ou logiciels comptables +- Export/import de données en différents formats +- Webhooks pour événements système + +## 4. Description fonctionnelle détaillée + +### 4.1 Structure générale + +- Interface responsive accessible sur tous supports +- Cinq modules principaux : Utilisateurs, Projets/Emplacements, Catégories, Équipements, Affectations +- Navigation intuitive avec accès contextuel aux fonctionnalités +- Dashboard personnalisable par type d'utilisateur + +### 4.2 Module de gestion des utilisateurs + +- Annuaire complet avec recherche avancée et filtres +- Gestion des profils avec historique d'activité +- Vue des équipements actuellement affectés +- Statistiques d'utilisation et de responsabilité matérielle +- Système de notification personnalisable + +### 4.3 Module de gestion des projets/emplacements + +- Structure adaptable selon le secteur d'activité +- Visualisation des équipements actuellement présents +- Timeline d'occupation des ressources +- Planification des besoins futurs +- Cartographie des emplacements physiques + +### 4.4 Module de gestion des catégories + +- Arborescence des catégories personnalisable +- Gestion des attributs spécifiques par catégorie +- Règles de nommage et d'attribution automatisées +- Templates pour accélérer la création d'équipements similaires +- Rapports analytiques par catégorie + +### 4.5 Module de gestion des équipements + +- Interface complète de gestion d'inventaire +- Fiche détaillée avec historique complet de chaque équipement +- Journal d'activité avec tous les mouvements et scans nfc/qr +- Suivi du cycle de vie (de l'acquisition à la mise au rebut) +- Planning de maintenance préventive +- Système d'alerte pour maintenance ou certification à renouveler + +### 4.6 Module de gestion des affectations + +- Processus guidé d'affectation avec validation +- Scan nfc/qr pour confirmation de prise en charge +- Vue calendaire des disponibilités +- Système de réservation anticipée +- Alertes de retour pour affectations arrivant à échéance +- Workflows configurables avec approbations multi-niveaux + +### 4.7 Fonctionnalités de recherche avancée + +- Recherche globale intelligente sur tous les critères +- Filtres contextuels et sauvegarde de recherches favorites +- Recherche par scan nfc/qr pour identification rapide +- Suggestions intelligentes basées sur l'historique + +### 4.8 Module d'administration et paramétrage + +- Configuration complète adaptée à chaque organisation +- Personnalisation de la terminologie et des champs +- Gestion des droits et rôles utilisateurs +- Audit logs pour toutes les actions système +- Paramétrage des notifications et alertes + +## 5. Interactions et automatisations + +### 5.1 Workflow de scan nfc/qr + +- Scan à l'entrée/sortie des zones de stockage +- Mise à jour automatique de la localisation +- Vérification de la légitimité du mouvement +- Création automatique d'affectation sur scan sortant +- Clôture automatique d'affectation sur scan entrant + +### 5.2 Interactions entre équipements + +- Gestion des relations parent/enfant entre équipements +- Suivi des assemblages/désassemblages +- Alertes sur incompatibilités potentielles +- Recommandations d'équipements complémentaires + +### 5.3 Automatisation des processus + +- Rappels automatiques pour retours d'équipements +- Alertes de maintenance basées sur l'utilisation réelle +- Détection d'anomalies dans les patterns d'utilisation +- Suggestions d'optimisation du parc + +Ce cahier des charges est destiné à servir de référence pour le développement d'une plateforme SaaS de gestion d'équipements adaptable à différents secteurs d'activité, avec un accent particulier sur l'automatisation via technologie nfc/qr et l'analyse avancée des données. + +---- +docs-and-prompts/market/tunnel-conversion.md +# Tunnel de Conversion ForTooling - Phase de Lancement + +## 1. Structure du Tunnel de Vente + +### Phase 1: Attraction (Acquisition) + +- **Objectif**: Attirer des prospects qualifiés vers la landing page +- **Canaux prioritaires**: Google Ads, LinkedIn, référencement naturel +- **Message principal**: "Solution innovante pour suivre vos équipements BTP à prix mini" +- **KPI**: Coût par clic qualifié, taux de rebond initial + +### Phase 2: Intérêt (Landing Page) + +- **Objectif**: Capter l'attention et démontrer la compréhension du problème +- **Méthode**: Hero section impactante + section problème/solution +- **Message clé**: "Fini les pertes d'équipements et le temps perdu à chercher" +- **KPI**: Taux de scroll, temps sur page + +### Phase 3: Considération (Démonstration Valeur) + +- **Objectif**: Prouver l'efficacité et le ROI de la solution +- **Méthode**: Section "Comment ça marche" + avantages + simulateur d'économies +- **Message clé**: "Simple, rapide et jusqu'à 70% moins cher que les alternatives" +- **KPI**: Interactions avec simulateur, vidéos vues + +### Phase 4: Conversion (Essai Gratuit) + +- **Objectif**: Inciter à l'essai gratuit de 14 jours +- **Méthode**: Offre spéciale lancement + formulaire simplifié + garanties +- **Message clé**: "Essayez sans risque pendant 14 jours - Programme pionnier" +- **KPI**: Taux de conversion vers essai gratuit + +### Phase 5: Onboarding (Post-Conversion) + +- **Objectif**: Maximiser l'adoption et l'usage pendant l'essai +- **Méthode**: Email séquentiels + appel de bienvenue + guide démarrage +- **Message clé**: "Voyez des résultats concrets en seulement quelques jours" +- **KPI**: Taux d'activation, % utilisation des fonctionnalités clés + +### Phase 6: Conversion finale (Devenir client) + +- **Objectif**: Transformer l'essai en abonnement payant +- **Méthode**: Démonstration ROI déjà réalisé + offre spéciale fin d'essai +- **Message clé**: "Continuez à économiser avec notre offre spéciale pionnier" +- **KPI**: Taux de conversion essai → client payant + +## 2. Optimisation du Formulaire d'Essai Gratuit + +### Principes clés + +- **Minimalisme**: Demander uniquement l'information essentielle +- **Étapes**: Limiter à une seule étape si possible (max 2) +- **Valeur perçue**: Mettre en avant ce qu'ils obtiennent immédiatement +- **Réduction des frictions**: Éliminer tout obstacle à la complétion + +### Informations à collecter (par ordre de priorité) + +1. Email professionnel (obligatoire) +2. Numéro de téléphone (obligatoire - crucial pour suivi) +3. Nom de l'entreprise (obligatoire) +4. Taille approximative du parc d'équipements (optionnel mais utile) + +### Éléments de réassurance + +- "Sans carte bancaire" +- "Configuration en 48h" +- "Données sécurisées et confidentielles" +- "Annulation en 1 clic" + +## 3. Séquence Emails Post-Inscription + +### Email 1: Confirmation immédiate + +- **Objet**: "Bienvenue dans l'aventure ForTooling! Voici la suite..." +- **Contenu**: Confirmation + prochaines étapes + calendrier rendez-vous onboarding +- **CTA**: "Planifier mon appel de démarrage rapide (15min)" + +### Email 2: J+1 - Guide de démarrage + +- **Objet**: "Votre guide étape par étape pour démarrer avec ForTooling" +- **Contenu**: PDF guide démarrage + vidéo courte + FAQ initiale +- **CTA**: "Voir la vidéo de démarrage (3min)" + +### Email 3: J+3 - Première vérification + +- **Objet**: "Avez-vous rencontré des difficultés avec ForTooling?" +- **Contenu**: Check-in + astuces clés + proposition d'aide +- **CTA**: "Répondre pour obtenir de l'aide" ou "Tout va bien!" + +### Email 4: J+7 - Milestone et fonctionnalités avancées + +- **Objet**: "Découvrez ces 3 fonctionnalités qui vous feront gagner du temps" +- **Contenu**: Fonctionnalités avancées + témoignage + astuce pro +- **CTA**: "Activer ces fonctionnalités" + +### Email 5: J+10 - Partage de cas d'usage + +- **Objet**: "Comment les entreprises BTP utilisent ForTooling (exemples concrets)" +- **Contenu**: Cas d'usage + scénarios + bonnes pratiques +- **CTA**: "Appliquer ces méthodes à votre entreprise" + +### Email 6: J+12 - Préparation fin d'essai + +- **Objet**: "Votre essai ForTooling se termine dans 2 jours - Voici votre offre spéciale" +- **Contenu**: Récapitulatif valeur + offre exclusive + procédure simple +- **CTA**: "Activer mon offre spéciale pionniers (-50%)" + +### Email 7: J+14 - Dernier jour + +- **Objet**: "DERNIER JOUR - Votre décision concernant ForTooling" +- **Contenu**: Options disponibles + rappel bénéfices + témoignages +- **CTA**: "Continuer avec ForTooling" ou "Planifier un dernier appel" + +### Email 8: J+15 - Récupération (si pas converti) + +- **Objet**: "Nous respectons votre décision, mais avant de nous quitter..." +- **Contenu**: Sondage court + offre dernière chance + possibilité extension +- **CTA**: "Bénéficier d'une semaine supplémentaire d'essai" + +## 4. Script d'Appel de Bienvenue + +### Objectif de l'appel + +Établir une relation, comprendre les besoins spécifiques, assurer le bon démarrage + +### Introduction (1min) + +"Bonjour [Prénom], merci d'avoir démarré votre essai de ForTooling! Je m'appelle [Votre nom] et je suis là pour m'assurer que vous puissiez tirer le maximum de votre période d'essai. Avez-vous quelques minutes pour que nous parlions de vos besoins spécifiques?" + +### Questions clés (5min) + +1. "Pouvez-vous me parler brièvement des défis que vous rencontrez actuellement avec la gestion de vos équipements?" +2. "Environ combien d'équipements souhaitez-vous suivre avec ForTooling?" +3. "Avez-vous déjà utilisé une solution similaire par le passé?" +4. "Qu'est-ce qui vous a incité à essayer ForTooling spécifiquement?" + +### Présentation personnalisée (5min) + +"D'après ce que vous me dites, je pense que ces fonctionnalités spécifiques pourraient vous être particulièrement utiles..." (adapter selon réponses) + +### Plan de démarrage (3min) + +"Voici ce que je vous propose comme plan pour ces 14 jours d'essai: + +1. Aujourd'hui/demain: Configuration initiale de votre compte +2. D'ici la fin de semaine: Étiquetage de vos premiers équipements (10-20) +3. Début semaine prochaine: Formation rapide de vos équipes (15min max) +4. Milieu de semaine prochaine: Premier bilan d'utilisation avec moi + Cela vous semble-t-il réalisable?" + +### Conclusion et prochaines étapes (1min) + +"Super! Je vais vous envoyer un récapitulatif par email. N'hésitez pas à me contacter directement à ce numéro si vous avez la moindre question. Notre objectif est que vous puissiez voir des résultats concrets avant la fin de votre période d'essai." + +## 5. Stratégie de Relance Fin d'Essai + +### Principes + +- Approche consultative plutôt que pression commerciale +- Focus sur valeur déjà obtenue pendant l'essai +- Offre spéciale avec délai limité + +### Timing des relances + +- J-3: Email préparatoire +- J-1: Relance téléphonique +- J+0: Email "dernier jour" +- J+1: Appel de récupération si non converti + +### Script d'appel J-1 + +"Bonjour [Prénom], c'est [Votre nom] de ForTooling. Je vous appelle car votre période d'essai se termine demain, et je voulais faire un point avec vous: + +1. Comment s'est passée votre expérience jusqu'à présent? +2. Avez-vous pu observer des améliorations dans la gestion de vos équipements? +3. Y a-t-il des questions ou préoccupations qui pourraient vous empêcher de continuer? + +Comme vous faites partie de nos premiers utilisateurs, nous avons une offre spéciale "Pionnier": -50% sur votre abonnement première année, ce qui ramène le coût à seulement [X]€ par mois. + +Souhaitez-vous bénéficier de cette offre pour continuer avec ForTooling?" + +## 6. Tactiques de Réduction des Abandons + +### Identifiez les signes d'alerte précoces + +- Non-connexion après 3 jours +- Moins de 5 équipements enregistrés +- Absence de scans après configuration + +### Actions préventives + +- Email personnalisé: "Besoin d'aide pour démarrer?" +- Appel proactif: "Puis-je vous aider avec la mise en place?" +- Offre d'extension: "Besoin de plus de temps? Essayez 7 jours supplémentaires" + +### Incitatifs de rétention + +- Débloquer fonctionnalité premium pendant l'essai +- Offrir configuration gratuite des 20 premiers équipements +- Proposer session de formation équipe offerte + +### Feedback sur les abandons + +- Sondage court et simple +- Appel de suivi non-commercial +- Offre de retour facilitée (données conservées 30 jours) + +---- +docs-and-prompts/market/strategie-marketing-honnete.md +# Stratégie Marketing ForTooling - Phase de Lancement + +## Positionnement Stratégique pour une Nouvelle Solution + +### USP (Unique Selling Proposition) + +"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." + +### Points de différenciation clés (Factuel et vérifiable) + +- **Prix ultra-compétitif** (50-70% moins cher que les options établies) +- **Zéro matériel coûteux** (QR codes/NFC vs balises GPS onéreuses) +- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) +- **Mise en place en moins de 48h** (vs semaines pour solutions traditionnelles) +- **ROI rapide et mesurable** (diminution des pertes, gain de temps) + +### Persona cibles prioritaires + +1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) +2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) +3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) + +## Avantages du Statut de Nouvelle Entreprise + +### Transformer votre nouveauté en force + +- **Agilité et réactivité**: Adaptation rapide aux besoins spécifiques des premiers clients +- **Support personnalisé**: Attention particulière aux premiers utilisateurs +- **Influence sur le développement**: Participation à l'évolution du produit +- **Conditions préférentielles**: Avantages exclusifs pour les premiers adoptants + +### Programme "Pionniers ForTooling" + +- Réduction tarifaire substantielle pour les 20 premiers clients +- Support direct avec les fondateurs/développeurs +- Mise en avant future (avec accord) comme partenaires de la première heure +- Webinaires exclusifs et rencontres networking + +## Utilisation Stratégique des Données Sectorielles + +### Statistiques BTP exploitables (à sourcer) + +- Taux moyen de perte d'équipements dans le secteur (15-20% annuel) +- Coût moyen du remplacement de matériel (X€/an pour une PME moyenne) +- Temps quotidien perdu à rechercher du matériel (20-30 min/personne/jour) +- Impact financier des retards de chantier liés aux problèmes d'équipement + +### Calculs de ROI à mettre en avant + +- Simulateur d'économies basé sur taille de l'entreprise et parc d'équipement +- Coût réel des pertes vs investissement ForTooling +- Valorisation du temps gagné en recherche de matériel +- Économies liées à la prolongation de la durée de vie des équipements + +## Approche Content Marketing Adaptée + +### Contenu de valeur à créer en priorité + +- Guide: "Comment réduire les pertes de matériel sur vos chantiers" +- Ebook: "Les coûts cachés d'une mauvaise gestion d'équipements" +- Calculateur: "Estimez vos pertes annuelles d'équipements" +- Checklist: "10 bonnes pratiques pour augmenter la durée de vie de votre matériel" + +### Partenariats de contenu stratégiques + +- Collaboration avec médias BTP pour articles d'expertise +- Interviews de dirigeants et experts du secteur sur leurs problématiques +- Webinaires co-organisés avec fournisseurs d'équipements +- Présence sur salons professionnels avec offre spéciale salon + +## Stratégie d'Acquisition Adaptée aux Débuts + +### Canaux prioritaires + +- LinkedIn: ciblage précis des décideurs BTP +- Google Ads: mots-clés spécifiques à forte intention +- Démarchage direct: approche personnalisée des premiers clients +- Réseaux d'entrepreneurs et associations BTP + +### Tactiques d'acquisition créatives + +- "Test Challenge": Essai comparatif de ForTooling vs méthode actuelle pendant 14 jours +- Démonstrations in situ sur petits parcs d'équipements +- Programme parrainage avant même le lancement +- Offres groupées pour fédérations/groupements d'entreprises BTP + +---- +docs-and-prompts/market/strategie-marketing-fortooling.md +# Stratégie Marketing et Tunnel de Conversion ForTooling + +## 1. Positionnement Stratégique + +### 1.1 Unique Selling Proposition (USP) + +"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." + +### 1.2 Points de différenciation clés + +- **Prix ultra-compétitif** (50-70% moins cher que les concurrents) +- **Zéro matériel coûteux** (utilisation de QR codes/NFC vs balises GPS onéreuses) +- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) +- **Mise en place en moins de 48h** (vs semaines pour solutions concurrentes) +- **ROI immédiat et mesurable** (diminution des pertes, gain de temps) + +### 1.3 Persona cibles prioritaires + +1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) +2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) +3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) + +## 2. Architecture du Tunnel de Conversion + +### 2.1 Étape 1: Attraction (Top du Funnel) + +- **SEO ciblé** sur requêtes problématiques ("perte matériel chantier", "gestion outillage BTP") +- **Google Ads** sur mots-clés transactionnels à fort intent +- **Posts LinkedIn** ciblant les décideurs BTP (format statistiques choc + solution) +- **Publicité dans médias spécialisés BTP** (print et digital) + +### 2.2 Étape 2: Intérêt (Landing Page) + +- **Hero section impactante**: + + - Headline: "Fini les pertes de matériel: suivez tous vos équipements BTP pour 1,90€ par jour" + - Sous-title: "Solution simple par QR code - Mise en place en 48h - Sans engagement" + - Démonstration vidéo courte (30s) montrant la simplicité d'utilisation + - CTA principal: "ESSAI GRATUIT 14 JOURS" (en orange, contrasté) + - Preuve sociale: "Déjà +3000 équipements suivis dans 47 entreprises BTP" + +- **Section problème-solution immédiate** (priorité #1): + | PROBLÈME | NOTRE SOLUTION | BÉNÉFICE CHIFFRÉ | + |----------|----------------|------------------| + | 15-20% des équipements perdus chaque année | Localisation instantanée par QR code | Économie de 5 000-15 000€/an | + | 30 min/jour perdues à chercher du matériel | Inventaire accessible en 3 clics | Gain de 125h/an/employé | + | Attribution floue et déresponsabilisation | Traçabilité complète par utilisateur | -70% d'équipements non retournés | + | Solutions concurrentes à 5-10K€ | Prix fixe ultra-compétitif | ROI dès le premier mois | + +### 2.3 Étape 3: Considération (Mid-Funnel) + +- **Démonstration du fonctionnement** (3 étapes ultra-simples): + + 1. **ÉTIQUETEZ** vos équipements avec nos QR codes ultra-résistants + 2. **SCANNEZ** pour attribuer ou déplacer (3 secondes par scan) + 3. **CONTRÔLEZ** votre parc complet depuis le dashboard + +- **Section témoignages** avec métriques précises: + + - "Nous avons réduit nos pertes d'équipements de 83% en 3 mois" - Martin D., Directeur, MTP Construction + - "Économie de 12 500€ la première année et gain de temps quotidien" - Sophie L., Resp. Logistique, BatiPro + - Inclure photos, logos d'entreprises et postes spécifiques + +- **Social proof renforcée**: + - Compteur en temps réel d'équipements suivis + - Logos clients (avec autorisations) + - Notation clients (4.8/5 basée sur X avis) + +### 2.4 Étape 4: Conversion (Bottom Funnel) + +- **Pricing stratégique**: + + - Afficher tarifs en "par jour" plutôt qu'en mensuel (perception de coût moindre) + - Proposer 3 formules avec celle du milieu pré-sélectionnée (technique d'ancrage) + - Comparer avec le "coût de ne rien faire" (pertes annuelles moyennes: 7500€) + - Garantie "satisfait ou remboursé 30 jours" (réduction du risque perçu) + +- **CTA d'essai gratuit omniprésent**: + - Formulaire d'inscription ultra-simplifié (email + téléphone uniquement) + - "Commencez en 2 minutes - Sans carte bancaire" + - Décompte de temps limité: "Offre spéciale: -20% les 3 premiers mois si vous vous inscrivez aujourd'hui" + +### 2.5 Étape 5: Onboarding (Post-conversion) + +- **Séquence email automatisée**: + + - J1: Guide de démarrage rapide + vidéo personnalisée + - J3: Check-in "Besoin d'aide?" + cas d'usage clés + - J7: Partage de succès clients similaires + - J10: Invitation démonstration personnalisée + - J12: Rappel fin d'essai + témoignages résultats + - J14: Offre spéciale première année + formulaire CB + +- **Relance téléphonique stratégique**: + - Appel à J5: "Comment se passe votre essai? Des questions?" + - Appel à J13: "Prêt à continuer? Offre spéciale réservée pour vous" + +## 3. Optimisation SEO Stratégique + +### 3.1 Mots-clés prioritaires + +- **Intention transactionnelle forte**: + + - "logiciel gestion équipement BTP" + - "suivi matériel chantier QR code" + - "solution traçabilité outils construction" + - "gestion inventaire entreprise BTP" + +- **Intention informationnelle** (content marketing): + - "comment réduire pertes matériel chantier" + - "coût perte équipement construction" + - "responsabilisation équipe BTP" + - "ROI gestion parc équipements" + +### 3.2 Structure de contenu SEO + +- **Pages de landing spécifiques par problématique**: + + - /reduction-pertes-materiels-chantier + - /suivi-outils-qr-code + - /gestion-attribution-equipements-btp + - /economie-gestion-materiel-construction + +- **Blog optimisé** (minimum 2 articles/mois): + - "Comment cette entreprise a économisé 15 000€ en réduisant ses pertes de matériel" + - "Guide: Calculez ce que vous coûtent vraiment vos pertes d'équipements" + - "5 techniques pour responsabiliser vos équipes sur le matériel" + - "Étude de cas: De l'Excel à ForTooling - Transformation digitale d'un parc matériel" + +### 3.3 Optimisations techniques + +- **Schema.org markup** pour: + + - Témoignages (Review Schema) + - Tarifs (Offer Schema) + - FAQ (FAQPage Schema) + - Organisation (Organization Schema) + +- **Core Web Vitals** optimisés pour mobile: + - LCP < 2.5s (images optimisées, serveur rapide) + - FID < 100ms (JavaScript non-bloquant) + - CLS < 0.1 (layout stable, fonts préchargées) + +## 4. Conversion Rate Optimization (CRO) + +### 4.1 Tests A/B prioritaires + +1. **Hero Section**: + + - Headline axé problème vs headline axé solution + - CTA "Essai gratuit" vs "Voir la démo en 2 min" + - Vidéo autoplay vs image statique + +2. **Formulaire de conversion**: + + - Minimal (email uniquement) vs standard (email + téléphone) + - Pop-up vs inline + - Avec/sans countdown timer + +3. **Preuve sociale**: + - Logos clients vs témoignages détaillés + - Statistiques chiffrées vs histoires de réussite + - Placement haut vs bas de page + +### 4.2 Micro-conversions à tracker + +- Pourcentage de scroll (≥70% = intent) +- Temps passé sur page (≥2min = intent) +- Clics sur témoignages (fort intent) +- Visionnage vidéo démo (fort intent) +- Ouverture FAQ (intent modéré) + +### 4.3 Objections à lever explicitement + +- **Objection prix**: "Plus abordable qu'un seul équipement perdu par mois" +- **Objection complexité**: "Prise en main en moins de 5 minutes, même sans compétence technique" +- **Objection temps**: "Déploiement en 48h sans perturber votre activité" +- **Objection internet**: "Fonctionne hors-ligne sur les chantiers isolés" +- **Objection engagement**: "Sans engagement - Résiliable à tout moment" + +## 5. Éléments Visuels Marketing Stratégiques + +### 5.1 Images à fort impact + +- **Avant/Après visuel**: Chaos d'équipements vs organisation parfaite +- **ROI visualisé**: Graphique économies réalisées vs coût solution +- **Contexte réel**: Photos sur chantiers authentiques, pas de stock photos +- **Process simplifié**: Infographie 3 étapes (étiqueter → scanner → contrôler) + +### 5.2 Vidéos persuasives + +- **Démo ultra-courte** (30s) en autoplay sans son: scan → dashboard → localisation +- **Témoignage client** (1min): problème → solution → résultats mesurables +- **Explication technique** (2min): pour rassurer décideurs techniques + +### 5.3 Confiance et crédibilité + +- **Badges sécurité/RGPD**: conformité, sécurité des données +- **Logos partenaires/clients**: reconnaissance par l'écosystème +- **Certifications**: labels qualité, innovation +- **Médias**: mentions presse spécialisée BTP + +## 6. Tactiques de Growth Hacking + +### 6.1 Acquisition non-conventionnelle + +- **Partenariats fournisseurs BTP**: offre groupée avec vendeurs d'équipements +- **Programme ambassadeur**: commission pour chaque entreprise référée +- **Webinaires ciblés**: "Comment réduire vos pertes d'équipements de 70% en 30 jours" +- **Défi gratuit**: "Testez pendant 14 jours et mesurez vos économies - Résultats garantis" + +### 6.2 Rétention optimisée + +- **Gamification**: score "d'efficacité matériel" comparé à la moyenne du secteur +- **Alertes ROI**: notifications des économies réalisées +- **Check-in trimestriel**: rapport personnalisé d'optimisation avec consultant +- **Communauté**: groupe privé d'échange entre responsables matériel + +### 6.3 Referral Engine + +- **Programme "Parrainez un artisan"**: 2 mois offerts pour chaque référence +- **Co-marketing**: témoignages clients en échange de visibilité +- **Contenu co-créé**: études de cas détaillées avec clients ambassadeurs + +## 7. Plan d'Implémentation Prioritaire + +### 7.1 Actions immédiates (J+0 à J+30) + +1. Refonte de la landing page avec structure de conversion optimisée +2. Mise en place des tunnels d'emails automatisés pré/post essai +3. Création de 3 témoignages clients détaillés (vidéo + texte) +4. Configuration tracking analytics conversion (objectifs GA4/Meta) +5. Lancement campagne Google Ads sur mots-clés prioritaires + +### 7.2 Seconde phase (J+30 à J+90) + +1. Développement de 5 articles de blog optimisés SEO +2. Création landing pages spécifiques par problématique +3. Mise en place programme de parrainage client +4. Lancement tests A/B principaux (headline, CTA, formulaire) +5. Développement automatisation relances essais gratuits + +### 7.3 KPIs critiques à suivre + +- Taux de conversion visiteur → essai gratuit (objectif: >5%) +- Taux de conversion essai → client payant (objectif: >30%) +- CAC (Coût d'Acquisition Client) (objectif: <3 mois de revenu) +- LTV (Lifetime Value) (objectif: >24 mois) +- Taux de churn mensuel (objectif: <3%) + +## 8. Messages Persuasifs Clés (Copywriting) + +### 8.1 Headlines A/B testés + +- "Stop aux 7500€ perdus chaque année en équipements égarés sur vos chantiers" +- "Suivez 100% de vos équipements BTP pour moins de 2€ par jour - Sans matériel coûteux" +- "Vos outils toujours localisés, vos équipes responsabilisées, votre budget préservé" +- "Cette solution QR code a permis à 47 entreprises BTP d'économiser 350 000€ de matériel" + +### 8.2 Éléments de friction à éliminer + +- Formulaire trop long (réduire au strict minimum) +- Jargon technique (simplifier le langage) +- Prix mensuel (préférer affichage quotidien ou annuel avec économies) +- Étapes multiples (réduire au maximum les clics vers conversion) + +### 8.3 Modificateurs de valeur perçue + +- Calcul personnalisé des économies potentielles +- Comparatif direct avec solutions concurrentes +- Démonstration du temps économisé (convertir en euros) +- Garantie "Satisfait ou Remboursé" proéminente + +--- + +**RAPPEL STRATÉGIQUE**: L'objectif principal n'est pas de "vendre" mais de convaincre d'essayer le produit gratuitement pendant 14 jours. La véritable conversion s'effectuera grâce à l'expérience produit elle-même et au processus d'onboarding soigneusement orchestré. + +---- +docs-and-prompts/market/pages-techniques-parcours.md +# Pages Techniques et Parcours Utilisateur + +## Pages Techniques et Légales + +### Pages Légales Essentielles + +1. **CGV/CGU** + + - Conditions claires et transparentes + - Langage accessible (éviter jargon juridique excessif) + - Sections bien structurées par thème + - Date de dernière mise à jour visible + +2. **Politique de confidentialité (RGPD)** + + - Données collectées et finalités + - Conservation et protection des données + - Droits des utilisateurs + - Utilisation des cookies + - Procédures de demande d'accès/suppression + +3. **Mentions légales** + + - Informations société + - Hébergement + - Directeur de publication + - Propriété intellectuelle + - Limitations de responsabilité + +4. **Conditions d'utilisation du service** + - Droits d'utilisation + - Restrictions d'usage + - Garanties et limites + - Résiliation et suspension + - Support et maintenance + +### Pages Techniques à Développer + +1. **Sécurité des données** + + - Architecture sécurisée + - Chiffrement et protection + - Sauvegardes et redondance + - Conformité RGPD + - Tests de sécurité réguliers + +2. **API et intégrations** + + - Documentation API (même basique pour le futur) + - Intégrations existantes ou prévues + - Procédure de demande d'accès API + - Cas d'usage d'intégration + - Support développeurs + +3. **Guide utilisateur/Centre d'aide** + - Navigation par rôle utilisateur + - Recherche intégrée + - Articles base de connaissances + - Vidéos tutoriels courtes + - FAQ technique détaillée + +## Optimisation des Parcours Utilisateur + +### Parcours d'Onboarding + +1. **Page "Premiers pas avec ForTooling"** + + - Guide visuel étape par étape + - Vidéo d'introduction (2-3 min) + - Checklist interactive de démarrage + - Jalons d'activation clairs + - Contact support dédié nouvel utilisateur + +2. **Guides spécifiques par profil utilisateur** + + - Pour administrateurs système + - Pour responsables matériel + - Pour utilisateurs terrain + - Pour chefs de chantier/projet + - Pour direction/décideurs (rapports) + +3. **Vidéos d'initiation courtes** + + - Série "Démarrer en 10 minutes" + - Tutoriels ciblés par fonctionnalité (1-2 min) + - Démos cas d'usage courants + - Astuces et raccourcis + - Questions fréquentes visuelles + +4. **Checklist de démarrage** + - Étapes essentielles séquentielles + - Indicateurs de progression + - Validation des étapes complétées + - Contenus d'aide contextuelle + - Célébration des succès d'activation + +### Programme Partenaires/Affiliés + +1. **Page Programme Ambassadeur** + + - Présentation des avantages + - Fonctionnement de la commission + - Témoignages partenaires (une fois existants) + - Outils marketing fournis + - FAQ programme partenaire + +2. **Commission référencement** + + - Grille de commission transparente + - Processus de tracking des leads + - Conditions de paiement + - Tableau de bord partenaire + - Support dédié partenaires + +3. **Processus d'inscription** + + - Critères d'éligibilité + - Formulaire de candidature + - Étapes de validation + - Formation initiale partenaire + - Kit de démarrage + +4. **Avantages et conditions** + - Avantages financiers détaillés + - Formations exclusives + - Accès anticipé nouvelles fonctionnalités + - Co-marketing opportunités + - Événements partenaires + +## Stratégie de Contenu par Phase + +### Phase 1 (Lancement - 3 premiers mois) + +1. Landing page principale +2. Pages Fonctionnalités, Tarifs, À propos +3. Page Comment ça marche +4. FAQ essentielle +5. Blog (3-5 articles initiaux) +6. Pages légales obligatoires + +**Priorité**: Conversion des premiers visiteurs en utilisateurs + +### Phase 2 (Développement - 3-6 mois) + +1. Pages sectorielles (2-3 premières) +2. Centre de ressources basique +3. Expansion du blog (1-2 articles/semaine) +4. Témoignages initiaux (dès premiers clients) +5. FAQ approfondie + +**Priorité**: SEO et création d'autorité dans le domaine + +### Phase 3 (Optimisation - 6-12 mois) + +1. Études de cas détaillées +2. Contenus avancés (webinaires, podcasts) +3. Pages partenaires et intégrations +4. Contenu généré par utilisateurs +5. Communauté utilisateurs + +**Priorité**: Rétention et expansion de l'écosystème + +## Recommandations pour Mise en Œuvre + +1. **Prioriser selon impact sur conversion**: + + - Landing page → Fonctionnalités → Tarifs → Comment ça marche + +2. **Créer une structure modulaire**: + + - Composants réutilisables (témoignages, CTA, avantages) + - Système de blocs cohérents + +3. **Maintenir cohérence visuelle et messagerie**: + + - Palette de couleurs consistante + - Mêmes messages clés sur toutes les pages + - Iconographie et illustrations harmonisées + +4. **Optimiser pour mobile en priorité**: + + - Interface simplifiée + - CTAs adaptés (plus grands sur mobile) + - Navigation intuitive + +5. **Intégrer mesure et analytics**: + - Événements de conversion sur chaque page + - Heatmaps sur pages critiques + - Tests A/B progressifs + +---- +docs-and-prompts/market/pages-support-conversion.md +# Pages de Support à la Conversion + +## Page "Comment ça marche" approfondie + +### Structure recommandée + +- Vidéo explicative (1-2 min) +- Processus détaillé en 5-7 étapes +- Zoom sur l'implémentation (48h) +- Témoignages d'experts sectoriels (si pas de clients, consultants BTP) +- FAQ spécifiques à l'implémentation +- CTA: "Voir une démo" + "Essai gratuit" + +### Processus à détailler + +1. **Inscription et configuration initiale** (15 min) + + - Création du compte entreprise + - Configuration des paramètres clés + - Personnalisation des catégories d'équipements + +2. **Import initial des équipements** (1-2h) + + - Upload de fichier Excel existant ou + - Saisie manuelle simplifiée ou + - Assistance à l'import par notre équipe + +3. **Étiquetage des équipements** (progressif) + + - Réception des QR codes résistants + - Application sur les équipements + - Scan initial de référencement + +4. **Formation des utilisateurs** (30 min) + + - Session de démonstration + - Guide pas-à-pas dans l'application + - Accès à des tutoriels vidéo + +5. **Déploiement terrain** (1-3 jours) + + - Premiers scans en conditions réelles + - Suivi des premières attributions + - Ajustement des processus si nécessaire + +6. **Optimisation continue** + - Analyse des premiers jours d'utilisation + - Recommandations personnalisées d'utilisation + - Ajout progressif d'équipements supplémentaires + +### Éléments visuels à inclure + +- Calendrier visuel du déploiement +- Screenshots étape par étape +- Exemples de QR codes et étiquettes +- Témoignages visuels de satisfaction + +## Page "FAQ" complète + +### Structure recommandée + +- Sections par thématique +- Questions organisées de générales à spécifiques +- Réponses concises mais complètes +- Liens vers pages détaillées +- CTA contextuel après chaque section + +### Catégories et questions essentielles + +#### Questions générales + +- Qu'est-ce que ForTooling exactement? +- Comment ForTooling se compare-t-il aux solutions existantes? +- Combien de temps pour être opérationnel? +- ForTooling est-il adapté à une petite entreprise? +- Puis-je essayer ForTooling avant de m'engager? + +#### Questions techniques + +- Les QR codes résistent-ils aux conditions de chantier? +- Que se passe-t-il si je n'ai pas de connexion sur le chantier? +- Quels appareils sont compatibles avec ForTooling? +- Les données sont-elles sécurisées? +- Puis-je exporter mes données facilement? + +#### Questions d'implémentation + +- Comment importer mon inventaire existant? +- Comment former mes équipes à l'utilisation? +- Combien de temps pour étiqueter tout mon matériel? +- Puis-je déployer progressivement la solution? +- Quel support recevrai-je pendant l'implémentation? + +#### Questions tarifaires + +- Y a-t-il des coûts cachés ou supplémentaires? +- Que comprend exactement chaque forfait? +- Puis-je changer de forfait en cours d'abonnement? +- Comment fonctionne la période d'essai? +- Offrez-vous des remises pour engagement annuel? + +#### Questions support et utilisation + +- Quel support est disponible en cas de problème? +- Proposez-vous des formations avancées? +- Comment suggérer de nouvelles fonctionnalités? +- Quelle est la disponibilité du service (uptime)? +- Comment contacter le support technique? + +## Page "Contact/Démo" + +### Structure recommandée + +- Options de contact (formulaire, email, téléphone) +- Planification de démo (calendrier Calendly) +- Processus de démonstration expliqué +- Formulaire contact intelligent (qualification leads) +- CTA secondaire: "Essai gratuit immédiat" + +### Formulaire de contact stratégique + +- Nom et prénom +- Email professionnel +- Téléphone (optionnel mais recommandé) +- Entreprise et fonction +- Taille de l'entreprise (dropdown) +- Nombre approximatif d'équipements à suivre +- Problématique principale (dropdown) +- Message personnalisé +- Préférence de contact (email, téléphone, visioconférence) + +### Section démo personnalisée + +- Titre: "Découvrez ForTooling en action sur vos cas d'usage" +- Explication: Démo personnalisée de 20 minutes +- Bénéfices: Focus sur vos besoins spécifiques +- Processus en 3 étapes (Prise de RDV → Préparation → Démonstration) +- Calendrier intégré pour réserver un créneau +- Témoignage sur qualité des démos + +### Informations de contact direct + +- Numéro de téléphone dédié +- Email de contact +- Horaires de disponibilité +- Temps de réponse moyen +- Chat en direct (si disponible) + +---- +docs-and-prompts/market/pages-seo-sectorielles.md +# Pages Stratégiques SEO et Contenu Sectoriel + +## Pages sectorielles/cas d'usage + +Ces pages ciblent des sous-segments spécifiques avec un contenu optimisé pour le référencement et la conversion. + +### 1. "ForTooling pour la maçonnerie" + +#### Structure recommandée + +- Introduction aux défis spécifiques de gestion d'équipements en maçonnerie +- Statistiques sectorielles (pertes d'outils, temps de recherche) +- Fonctionnalités ForTooling adaptées à la maçonnerie +- Catégories d'équipements pré-configurées +- Cas d'usage concrets (chantier type) +- Bénéfices chiffrés spécifiques +- Témoignage expert/consultant secteur (si pas encore de client) +- CTA sectoriel + +#### Mots-clés à cibler + +- gestion équipement maçonnerie +- suivi matériel chantier construction +- localisation outils maçons +- QR code suivi bétonnière/coffrage +- gestion prêt matériel maçonnerie + +#### Équipements spécifiques à mentionner + +- Bétonnières et malaxeurs +- Échafaudages et étais +- Outillage manuel spécifique +- Coffrages et banches +- Équipements de mesure et niveau + +### 2. "ForTooling pour l'électricité/plomberie" + +#### Structure recommandée + +- Introduction aux problématiques des artisans multi-sites +- Coût des outils spécialisés et impact des pertes +- Fonctionnalités ForTooling pour interventions multiples +- Gestion des équipements techniques coûteux +- Attribution aux techniciens itinérants +- Suivi et maintenance des outils de mesure +- ROI calculé pour artisan type +- CTA adapté + +#### Mots-clés à cibler + +- gestion outillage électricien +- suivi matériel plomberie +- attribution équipement techniciens +- traçabilité outils électroportatifs +- inventaire camion artisan + +#### Équipements spécifiques à mentionner + +- Outillage électroportatif +- Appareils de mesure et test +- Échelles et accès en hauteur +- Équipements de sécurité +- Stock véhicules d'intervention + +### 3. "ForTooling pour les locations de matériel" + +#### Structure recommandée + +- Introduction aux défis de la location d'équipements +- Problématique des retours et suivi +- Fonctionnalités de gestion entrées/sorties +- Traçabilité complète et historique +- Suivi état et maintenance des équipements +- Intégration facturation et gestion client +- Avantages compétitifs pour loueurs +- CTA spécifique location + +#### Mots-clés à cibler + +- gestion parc location BTP +- suivi retour équipements loués +- QR code location matériel +- logiciel gestion entrées sorties matériel +- traçabilité équipements location + +#### Fonctionnalités spécifiques à mettre en avant + +- Check-in/check-out rapide +- Suivi état avant/après location +- Historique par client/équipement +- Alertes retards de retour +- Planning disponibilité matériel + +### 4. "ForTooling pour les chefs de chantier" + +#### Structure recommandée + +- Introduction aux défis quotidiens des chefs de chantier +- Impact sur la productivité et planning +- Fonctionnalités d'allocation des ressources +- Visibilité en temps réel sur les équipements +- Planification besoins matériels par phase +- Responsabilisation des équipes +- Gain de temps quotidien estimé +- CTA orienté productivité + +#### Mots-clés à cibler + +- gestion équipement chef chantier +- planification ressources matérielles BTP +- disponibilité outils chantier +- responsabilisation équipe matériel +- optimisation utilisation équipements construction + +#### Avantages spécifiques à mettre en avant + +- Réduction temps recherche matériel +- Anticipation besoins par phase chantier +- Suivi utilisation par équipe/ouvrier +- Réduction conflits attribution matériel +- Meilleure planification ressources + +## Blog et Ressources + +### Catégories d'articles à développer + +1. **Guides pratiques** + + - Conseils d'optimisation de gestion d'équipements + - Tutoriels étape par étape + - Check-lists et processus + +2. **Études sectorielles** + + - Statistiques et tendances BTP + - Benchmarks et comparatifs + - Analyse coûts cachés + +3. **Conseils et meilleures pratiques** + + - Organisation et méthodes + - Responsabilisation des équipes + - Optimisation des processus + +4. **Innovations technologiques** + + - Nouveautés dans le BTP + - Technologies de traçabilité + - Digitalisation des chantiers + +5. **Témoignages et cas d'usage** + - Interviews experts + - Retours d'expérience + - Études de cas + +### Articles initiaux prioritaires + +1. **"Les coûts cachés d'une mauvaise gestion d'équipements BTP"** + + - Chiffrer les pertes financières réelles + - Impact sur productivité et délais + - Coûts indirects (recherche, remplacement, conflits) + - Solution et calcul ROI + +2. **"Comment réduire de 70% vos pertes de matériel sur chantier"** + + - Statistiques pertes secteur BTP + - Causes principales identifiées + - Méthodologie de réduction en 5 étapes + - Technologies facilitantes + +3. **"QR codes vs RFID vs GPS: quelle technologie de suivi pour vos équipements?"** + + - Comparatif détaillé des technologies + - Avantages/inconvénients de chaque solution + - Critères de choix selon besoins + - Analyse coûts/bénéfices + +4. **"5 indicateurs clés pour évaluer l'efficacité de votre gestion de parc matériel"** + + - KPIs essentiels à suivre + - Méthodes de calcul et benchmarks + - Outils de mesure recommandés + - Plan d'amélioration continu + +5. **"Guide: Comment mettre en place un système de traçabilité en 1 semaine"** + - Planification et préparation + - Étapes jour par jour + - Ressources nécessaires + - Conseils pour adoption rapide + +## Centre de Ressources + +### Types de contenus à proposer + +- **Guides téléchargeables (PDF)** + + - Guides approfondis et bien structurés + - Design professionnel avec illustrations + - Contenu actionnable et pratique + +- **Templates et calculateurs (Excel)** + + - Outils prêts à l'emploi + - Formules et automatisations utiles + - Instructions d'utilisation claires + +- **Checklists imprimables** + + - Format synthétique et pratique + - Points essentiels à vérifier + - Personnalisables par l'utilisateur + +- **Vidéos tutoriels** + + - Courtes (3-5 minutes maximum) + - Démonstrations pas-à-pas + - Sous-titrées et bien structurées + +- **Webinaires enregistrés** + - Présentations thématiques (30-45 min) + - Q&A incluses + - Slides téléchargeables + +### Ressources initiales prioritaires + +1. **"Guide ultime de la gestion d'équipements BTP" (ebook)** + + - 15-20 pages approfondies + - Illustrations et schémas + - Conseils pratiques et méthodologie + - Études de cas et exemples + +2. **"Calculateur ROI ForTooling" (spreadsheet interactif)** + + - Calculateur d'économies personnalisé + - Projection sur 1, 2 et 3 ans + - Comparaison avant/après + - Graphiques automatisés + +3. **"Checklist: Préparer votre migration vers un système digital"** + + - Liste de contrôle pré-migration + - Étapes essentielles chronologiques + - Points de vigilance et conseils + - Format imprimable A4 + +4. **"10 astuces pour maximiser la durée de vie de vos équipements"** + - Guide pratique maintenance préventive + - Conseils stockage et manipulation + - Fréquences d'entretien recommandées + - Estimation économies réalisables + +---- +docs-and-prompts/market/pages-essentielles.md +# Pages Essentielles à Développer pour ForTooling + +## Page "Fonctionnalités" détaillée + +### Structure recommandée + +- Introduction avec bénéfices globaux +- Sections par fonctionnalité majeure avec captures d'écran +- Comparaison discrète avec solutions concurrentes +- Cas d'usage par fonctionnalité +- CTA: "Essayer gratuitement" + "Demander une démo" + +### Éléments clés à inclure + +- **Module Inventaire** + + - Catalogage complet des équipements + - Catégorisation flexible adaptée au BTP + - Informations techniques et commerciales + - Gestion des cycles de vie (acquisition → maintenance → retrait) + +- **Module Suivi QR/NFC** + + - Processus de scan rapide (3 secondes) + - Localisation instantanée des équipements + - Historique complet des mouvements + - Fonctionnement hors-ligne sur chantier + +- **Module Attribution** + + - Assignation aux utilisateurs/équipes + - Attribution à des projets/chantiers + - Gestion des dates de retour prévues + - Alertes de non-retour automatiques + +- **Module Reporting** + + - Dashboard personnalisable + - Statistiques d'utilisation et disponibilité + - Calcul ROI et économies réalisées + - Exports PDF/Excel des rapports clés + +- **Fonctionnalités spéciales BTP** + - Étiquettes ultra-résistantes (poussière, eau, UV) + - Interface utilisable avec gants de chantier + - Vocabulaire et catégories pré-configurés BTP + - Champs personnalisés adaptés au secteur + +## Page "Tarifs" transparente + +### Structure recommandée + +- Introduction sur approche tarifaire (transparence, simplicité) +- 3 plans avec options clairement définies +- Comparaison des fonctionnalités par plan +- FAQ spécifiques aux prix +- CTA: "Démarrer avec [plan]" + "Contact commercial" + +### Plans tarifaires + +1. **Plan Essentiel** + + - Pour TPE/artisans (1-5 utilisateurs) + - Jusqu'à 100 équipements + - Fonctionnalités de base + - Prix: [X]€/mois ou [Y]€/jour + +2. **Plan Business** + + - Pour PME (5-20 utilisateurs) + - Jusqu'à 500 équipements + - Fonctionnalités avancées + rapports + - Prix: [X]€/mois ou [Y]€/jour + +3. **Plan Enterprise** + - Pour grandes entreprises (20+ utilisateurs) + - Équipements illimités + - Toutes fonctionnalités + personnalisation + - Prix: [X]€/mois ou [Y]€/jour + +### Avantages tarifaires à mettre en avant + +- Pas de coût matériel supplémentaire +- Économies réalisées vs pertes actuelles +- Prix par jour (perception moins coûteuse) +- Engagement flexible ou remise annuelle +- Offre spéciale de lancement (-50% premiers clients) + +## Page "À propos / Notre histoire" + +### Structure recommandée + +- Histoire de création (problème observé → solution) +- Mission et vision (démocratiser la gestion d'équipements BTP) +- Équipe (même petite, montrer les visages) +- Valeurs (simplicité, accessibilité, innovation) +- Programme Pionnier mis en avant +- CTA: "Rejoindre l'aventure ForTooling" + +### Éléments narratifs à développer + +- Origine de l'idée (expérience terrain BTP, observation problématiques) +- Approche différenciatrice (simplicité vs solutions complexes existantes) +- Vision du futur de la gestion d'équipements +- Parcours de développement du produit +- Ambitions et roadmap produit à venir + +### Éléments de confiance + +- Expertise combinée BTP et technologie +- Approche centrée sur problématiques terrain +- Témoignages experts/consultants sectoriels +- Mentions médias/partenaires (si disponibles) +- Engagement qualité et support réactif + +---- +docs-and-prompts/market/contenu-landing-page.md +# Contenu Optimisé pour Landing Page ForTooling + +## 1. Hero Section + +### Titre Principal (Options) + +- "Gérez enfin vos équipements BTP sans vous ruiner" +- "Suivez tous vos équipements BTP pour moins de 2€ par jour" +- "Solution innovante pour localiser votre matériel de chantier" + +### Sous-titre + +"Solution simple par QR code - Mise en place en 48h - Sans engagement" + +### CTA Principal + +"ESSAYEZ GRATUITEMENT PENDANT 14 JOURS" + +### Mention Offre de Lancement + +"OFFRE SPÉCIALE LANCEMENT: -50% pour nos 20 premiers clients + implémentation offerte" + +### Visuel Stratégique + +Image/vidéo montrant la solution en action sur un chantier réel + +- Scan QR code sur un équipement +- Vue du dashboard avec localisation +- Interface mobile en situation de chantier + +## 2. Section Problème-Solution + +### Introduction + +"Les entreprises du BTP font face à des défis quotidiens dans la gestion de leur matériel. ForTooling apporte une solution simple et abordable." + +### Tableau Problème-Solution + +| PROBLÈME DANS LE SECTEUR | NOTRE SOLUTION | BÉNÉFICE POTENTIEL | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Les entreprises BTP perdent en moyenne 15-20% de leurs équipements chaque année\* | Localisation instantanée par QR code et historique complet des mouvements | Réduction drastique des pertes et vols d'équipements | +| Jusqu'à 30 minutes par jour perdues à chercher du matériel sur les chantiers\* | Inventaire accessible en 3 clics avec localisation précise | Gain de temps quotidien pour vos équipes | +| Attribution floue menant à la déresponsabilisation | Traçabilité complète par utilisateur et notifications de non-retour | Responsabilisation des équipes et meilleur soin du matériel | +| Solutions traditionnelles complexes et onéreuses (5-10K€) | Prix fixe ultra-compétitif sans matériel coûteux | ROI rapide et budget maîtrisé | + +\*Selon étude sectorielle BTP Magazine 2023 + +## 3. Comment Ça Marche + +### Étape 1: ÉTIQUETEZ + +"Appliquez nos QR codes ultra-résistants sur vos équipements" + +- Étiquettes waterproof et résistantes aux chocs +- Installation en quelques secondes par équipement +- Compatible avec tous types d'outils et machines + +### Étape 2: SCANNEZ + +"Utilisez votre smartphone pour scanner lors des mouvements" + +- Scan rapide (3 secondes) lors des prises/retours +- Attribution à un utilisateur, projet ou emplacement +- Fonctionne même sans connexion internet sur le chantier + +### Étape 3: CONTRÔLEZ + +"Accédez à votre tableau de bord pour tout visualiser" + +- Vue d'ensemble de votre parc matériel +- Localisation actualisée de chaque équipement +- Historique complet des mouvements et utilisations +- Alertes automatiques pour équipements non retournés + +## 4. Avantages Clés ForTooling + +### Simplicité Extrême + +"Interface conçue pour être utilisée sur chantier, même avec des gants" + +- Prise en main en moins de 5 minutes +- Pas de formation complexe nécessaire +- Utilisable par tous vos collaborateurs + +### Prix Imbattable + +"Solution jusqu'à 70% moins chère que les alternatives traditionnelles" + +- À partir de 1,90€ par jour pour une PME +- Sans achat de matériel coûteux +- ROI généralement atteint dès le premier mois + +### Adapté au Terrain + +"Conçu pour résister aux conditions difficiles des chantiers" + +- Étiquettes ultra-résistantes (poussière, eau, chocs) +- Application mobile robuste et réactive +- Mode hors-ligne pour chantiers isolés + +### Déploiement Express + +"Opérationnel en 48h, sans perturber votre activité" + +- Assistance à la mise en place incluse +- Import facile de vos inventaires existants +- Support réactif par téléphone et email + +## 5. Offre Spéciale Lancement + +### Programme Pionnier ForTooling + +"Rejoignez nos premiers utilisateurs et bénéficiez d'avantages exclusifs" + +- **50% de réduction** sur l'abonnement première année +- **Mise en place et formation offertes** (valeur 500€) +- **Support prioritaire** avec accès direct à l'équipe +- **Influence sur les futures fonctionnalités** + +_Limité aux 20 premiers clients_ + +### CTA Principal Renforcé + +"RÉSERVEZ VOTRE PLACE DANS LE PROGRAMME PIONNIER" + +### Garantie Satisfaction + +"Essai 14 jours sans engagement - Satisfait ou remboursé 30 jours" + +## 6. FAQ Stratégique + +Questions traitant directement les objections potentielles: + +- **Q: Est-ce vraiment adapté à une entreprise qui débute avec la gestion numérique?** + R: Absolument! ForTooling a été conçu pour être aussi simple que possible, même pour les entreprises sans compétences techniques particulières. + +- **Q: Les QR codes résistent-ils vraiment aux conditions de chantier?** + R: Nos étiquettes sont spécialement conçues pour l'environnement BTP - résistantes à l'eau, la poussière, les UV et les chocs modérés. + +- **Q: Comment ForTooling se compare aux grandes solutions du marché?** + R: Nous offrons les fonctionnalités essentielles des grandes solutions (suivi, attribution, historique) mais à une fraction du prix et sans la complexité inutile. + +- **Q: Que se passe-t-il si nous n'avons pas de connexion sur le chantier?** + R: L'application fonctionne parfaitement hors-ligne et synchronise automatiquement les données dès qu'une connexion est disponible. + +- **Q: Combien de temps pour être opérationnel?** + R: La plupart de nos clients sont pleinement opérationnels en 24-48h, incluant l'étiquetage de leurs premiers équipements. + +## 7. CTA Final + +### Appel à l'action de clôture + +"Rejoignez les entreprises BTP qui transforment leur gestion de matériel" + +### Formulaire Simplifié + +- Email professionnel +- Numéro de téléphone +- Taille approximative du parc d'équipements + +### Bouton d'envoi + +"DÉMARRER MON ESSAI GRATUIT" + +### Réassurance finale + +"Sans engagement - Configuration en 48h - Support inclus" + +---- +.vscode/tasks.json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run ESLint Fix", + "type": "shell", + "command": "bun run lint:fix", + "group": "build", + "presentation": { + "reveal": "silent", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} + +---- +.husky/pre-commit +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Check Prettier formatting +npm run format:check || ( + echo '❌ Prettier check failed.'; + false; +) + +# Check ESLint rules +npm run lint || ( + echo '❌ ESLint check failed.'; + false; +) +---- +.husky/_/prepare-commit-msg +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/pre-rebase +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/pre-push +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/pre-merge-commit +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/pre-commit +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/pre-auto-gc +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/pre-applypatch +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/post-rewrite +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/post-merge +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/post-commit +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/post-checkout +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/post-applypatch +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/husky.sh +echo "husky - DEPRECATED + +Please remove the following two lines from $0: + +#!/usr/bin/env sh +. \"\$(dirname -- \"\$0\")/_/husky.sh\" + +They WILL FAIL in v10.0.0 +" +---- +.husky/_/h +#!/usr/bin/env sh +[ "$HUSKY" = "2" ] && set -x +n=$(basename "$0") +s=$(dirname "$(dirname "$0")")/$n + +[ ! -f "$s" ] && exit 0 + +if [ -f "$HOME/.huskyrc" ]; then + echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh" +fi +i="${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh" +[ -f "$i" ] && . "$i" + +[ "${HUSKY-}" = "0" ] && exit 0 + +export PATH="node_modules/.bin:$PATH" +sh -e "$s" "$@" +c=$? + +[ $c != 0 ] && echo "husky - $n script failed (code $c)" +[ $c = 127 ] && echo "husky - command not found in PATH=$PATH" +exit $c + +---- +.husky/_/commit-msg +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.husky/_/applypatch-msg +#!/usr/bin/env sh +. "$(dirname "$0")/h" +---- +.cursor/rules/rules-diagram-mermaid.mdc +--- +description: +globs: +alwaysApply: true +--- +erDiagram +Organization { + string id PK + string name + string email + string phone + string address + json settings + string clerkId + string stripeCustomerId + string subscriptionId + string subscriptionStatus + string priceId + date created + date updated +} + +User { + string id PK + string name + string email + string phone + string role + boolean isAdmin + boolean canLogin + string lastLogin + file avatar + boolean verified + boolean emailVisibility + string clerkId + date created + date updated +} + +Equipment { + string id PK + string organizationId FK + string name + string qrNfcCode + string tags + editor notes + date acquisitionDate + string parentEquipmentId FK + date created + date updated +} + +Project { + string id PK + string organizationId FK + string name + string address + editor notes + date startDate + date endDate + date created + date updated +} + +Assignment { + string id PK + string organizationId FK + string equipmentId FK + string assignedToUserId FK + string assignedToProjectId FK + date startDate + date endDate + editor notes + date created + date updated +} + +Image { + string id PK + string title + string alt + string caption + file image + date created + date updated +} + +Organization ||--o{ User : has +Organization ||--o{ Equipment : owns +Organization ||--o{ Project : manages +Organization ||--o{ Assignment : oversees + +User }o--o{ Assignment : "is assigned to" + +Equipment }o--o{ Assignment : "is assigned via" +Equipment }o--o{ Equipment : "parent/child" + +Project }o--o{ Assignment : includes + +--END-- \ No newline at end of file From 9ba909c7394d30aff1c3fc7c07cb6bf9d15d0788 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 14:55:48 +0200 Subject: [PATCH 09/73] feat(docs): remove outdated documentation files - Delete the comprehensive requirements document for equipment management - Remove the diagram file outlining data relationships - Eliminate landing page content that is no longer relevant - Clear out essential pages for market strategy and technical user journeys --- docs-and-prompts/cahier-des-charges.md | 207 ------------- docs-and-prompts/diagram-mermaid.md | 97 ------ .../market/contenu-landing-page.md | 167 ----------- docs-and-prompts/market/pages-essentielles.md | 114 -------- .../market/pages-seo-sectorielles.md | 252 ---------------- .../market/pages-support-conversion.md | 147 ---------- .../market/pages-techniques-parcours.md | 189 ------------ .../market/strategie-marketing-fortooling.md | 276 ------------------ .../market/strategie-marketing-honnete.md | 85 ------ docs-and-prompts/market/tunnel-conversion.md | 206 ------------- docs-and-prompts/stack-technique.md | 150 ---------- docs-and-prompts/technique-prompt-system.md | 146 --------- 12 files changed, 2036 deletions(-) delete mode 100644 docs-and-prompts/cahier-des-charges.md delete mode 100644 docs-and-prompts/diagram-mermaid.md delete mode 100644 docs-and-prompts/market/contenu-landing-page.md delete mode 100644 docs-and-prompts/market/pages-essentielles.md delete mode 100644 docs-and-prompts/market/pages-seo-sectorielles.md delete mode 100644 docs-and-prompts/market/pages-support-conversion.md delete mode 100644 docs-and-prompts/market/pages-techniques-parcours.md delete mode 100644 docs-and-prompts/market/strategie-marketing-fortooling.md delete mode 100644 docs-and-prompts/market/strategie-marketing-honnete.md delete mode 100644 docs-and-prompts/market/tunnel-conversion.md delete mode 100644 docs-and-prompts/stack-technique.md delete mode 100644 docs-and-prompts/technique-prompt-system.md diff --git a/docs-and-prompts/cahier-des-charges.md b/docs-and-prompts/cahier-des-charges.md deleted file mode 100644 index 8290389..0000000 --- a/docs-and-prompts/cahier-des-charges.md +++ /dev/null @@ -1,207 +0,0 @@ -## 1. Contexte et problématique générale - -### 1.1 Problématique adressée - -De nombreuses entreprises possèdent et gèrent un parc d'équipements qu'elles doivent suivre, attribuer et entretenir. Ces équipements peuvent représenter plusieurs dizaines à centaines d'articles différents (outils, matériel technique, appareils spécialisés, etc.). - -Les systèmes traditionnels de gestion présentent des lacunes importantes : - -- Suivi manuel chronophage et source d'erreurs -- Difficulté à localiser rapidement les équipements -- Absence d'historique fiable des mouvements et utilisations -- Complexité pour gérer les attributions -- Manque de visibilité globale sur l'état du parc -- Coût très elevé pour des balises gps précies (e.g hilti etc) -- Impossibilité d'appliquer ça sur des éléments autres - -## 2. Objectifs de la plateforme SaaS - -Développer une plateforme SaaS de gestion d'équipements qui permettra de : - -- Centraliser l'inventaire complet du parc matériel -- Suivre la localisation de chaque équipement en temps réel grâce à des étiquettes nfc/qr -- Gérer l'attribution des équipements aux utilisateurs et aux projets/emplacements -- Automatiser la détection des entrées/sorties d'équipements via des points de scan -- Conserver l'historique de tous les mouvements et utilisations -- Fournir des analyses et statistiques d'utilisation avancées -- Offrir une solution adaptable à différents secteurs d'activité - -## 3. Besoins fonctionnels détaillés - -### 3.1 Gestion multi-organisations - -- Support de plusieurs organisations clientes avec isolation complète des données -- Paramétrage par organisation (terminologie, champs personnalisés, flux de travail) -- Gestion des rôles et permissions par organisation - -### 3.2 Gestion des équipements - -- Inventaire complet avec informations détaillées : - - Référence unique et code NFC/qr associé - - Nom et description - - Date d'acquisition et valeur - - État et niveau d'usure - - Spécifications techniques (type, marque, modèle, etc.) - - Catégorie de rattachement - - Champs personnalisables selon le secteur d'activité -- Création, modification et suppression d'équipements -- Association d'un équipement à une catégorie spécifique -- Support pour documentation technique, photos et fichiers associés -- Gestion des maintenances préventives et curatives - -### 3.3 Suivi automatisé par NFC // ou SCAN QR Code - -- Intégration avec des étiquettes nfc/qr à faible coût / ou équivalent -- Points de scan aux entrées/sorties des zones de stockage -- Scan mobile via smartphones/tablettes pour vérification terrain -- Détection automatique des mouvements d'équipements -- Alertes en cas de sortie non autorisée - - mail - - sms - - alerte perso -- Cartographie des dernières localisations connues - -### 3.4 Gestion des affectations - -- Attribution d'équipements à : - - Un utilisateur/employé - - Un projet/chantier - - Un emplacement physique -- Enregistrement des dates de début et fin d'affectation -- Affectation groupée de plusieurs équipements simultanément -- Workflows d'approbation configurables -- Historique complet des affectations - -### 3.5 Gestion des utilisateurs - -- Enregistrement des informations sur les utilisateurs : - - Profil complet (nom, prénom, contact, etc.) - - Rôle et permissions dans le système - - Département/équipe de rattachement -- Suivi des équipements attribués à chaque utilisateur -- Gestion des accès par niveau de permission - -### 3.6 Gestion des projets/emplacements/chantiers - -- Structure flexible adaptable selon les besoins : - - Projets temporaires avec dates de début/fin - - Emplacements physiques permanents - - Zones géographiques -- Hiérarchisation possible (bâtiment > étage > pièce) -- Géolocalisation et cartographie -- Suivi des équipements affectés - -### 3.7 Catégorisation des équipements - -- Système de catégories et sous-catégories multiniveau -- Attributs spécifiques par catégorie d'équipement -- Système de préfixage automatique des références -- Organisation logique adaptée au secteur d'activité - -### 3.8 Analyses et statistiques avancées - -- Dashboard personnalisable avec indicateurs clés -- Rapports sur les taux d'utilisation des équipements -- Analyses prédictives pour planification des besoins -- Alertes sur équipements sous-utilisés ou sur-utilisés -- Statistiques par utilisateur, projet, catégorie et équipement -- Rapports exportables dans différents formats - -### 3.9 Intégration et API - -- API REST complète pour intégration avec d'autres systèmes -- Intégration possible avec des ERP, GMAO, ou logiciels comptables -- Export/import de données en différents formats -- Webhooks pour événements système - -## 4. Description fonctionnelle détaillée - -### 4.1 Structure générale - -- Interface responsive accessible sur tous supports -- Cinq modules principaux : Utilisateurs, Projets/Emplacements, Catégories, Équipements, Affectations -- Navigation intuitive avec accès contextuel aux fonctionnalités -- Dashboard personnalisable par type d'utilisateur - -### 4.2 Module de gestion des utilisateurs - -- Annuaire complet avec recherche avancée et filtres -- Gestion des profils avec historique d'activité -- Vue des équipements actuellement affectés -- Statistiques d'utilisation et de responsabilité matérielle -- Système de notification personnalisable - -### 4.3 Module de gestion des projets/emplacements - -- Structure adaptable selon le secteur d'activité -- Visualisation des équipements actuellement présents -- Timeline d'occupation des ressources -- Planification des besoins futurs -- Cartographie des emplacements physiques - -### 4.4 Module de gestion des catégories - -- Arborescence des catégories personnalisable -- Gestion des attributs spécifiques par catégorie -- Règles de nommage et d'attribution automatisées -- Templates pour accélérer la création d'équipements similaires -- Rapports analytiques par catégorie - -### 4.5 Module de gestion des équipements - -- Interface complète de gestion d'inventaire -- Fiche détaillée avec historique complet de chaque équipement -- Journal d'activité avec tous les mouvements et scans nfc/qr -- Suivi du cycle de vie (de l'acquisition à la mise au rebut) -- Planning de maintenance préventive -- Système d'alerte pour maintenance ou certification à renouveler - -### 4.6 Module de gestion des affectations - -- Processus guidé d'affectation avec validation -- Scan nfc/qr pour confirmation de prise en charge -- Vue calendaire des disponibilités -- Système de réservation anticipée -- Alertes de retour pour affectations arrivant à échéance -- Workflows configurables avec approbations multi-niveaux - -### 4.7 Fonctionnalités de recherche avancée - -- Recherche globale intelligente sur tous les critères -- Filtres contextuels et sauvegarde de recherches favorites -- Recherche par scan nfc/qr pour identification rapide -- Suggestions intelligentes basées sur l'historique - -### 4.8 Module d'administration et paramétrage - -- Configuration complète adaptée à chaque organisation -- Personnalisation de la terminologie et des champs -- Gestion des droits et rôles utilisateurs -- Audit logs pour toutes les actions système -- Paramétrage des notifications et alertes - -## 5. Interactions et automatisations - -### 5.1 Workflow de scan nfc/qr - -- Scan à l'entrée/sortie des zones de stockage -- Mise à jour automatique de la localisation -- Vérification de la légitimité du mouvement -- Création automatique d'affectation sur scan sortant -- Clôture automatique d'affectation sur scan entrant - -### 5.2 Interactions entre équipements - -- Gestion des relations parent/enfant entre équipements -- Suivi des assemblages/désassemblages -- Alertes sur incompatibilités potentielles -- Recommandations d'équipements complémentaires - -### 5.3 Automatisation des processus - -- Rappels automatiques pour retours d'équipements -- Alertes de maintenance basées sur l'utilisation réelle -- Détection d'anomalies dans les patterns d'utilisation -- Suggestions d'optimisation du parc - -Ce cahier des charges est destiné à servir de référence pour le développement d'une plateforme SaaS de gestion d'équipements adaptable à différents secteurs d'activité, avec un accent particulier sur l'automatisation via technologie nfc/qr et l'analyse avancée des données. diff --git a/docs-and-prompts/diagram-mermaid.md b/docs-and-prompts/diagram-mermaid.md deleted file mode 100644 index 0515908..0000000 --- a/docs-and-prompts/diagram-mermaid.md +++ /dev/null @@ -1,97 +0,0 @@ -# Diagram - -```text -erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} - -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} - -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} - -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} - -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} - -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} - -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees - -User }o--o{ Assignment : "is assigned to" - -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" - -Project }o--o{ Assignment : includes -``` diff --git a/docs-and-prompts/market/contenu-landing-page.md b/docs-and-prompts/market/contenu-landing-page.md deleted file mode 100644 index 2bb26eb..0000000 --- a/docs-and-prompts/market/contenu-landing-page.md +++ /dev/null @@ -1,167 +0,0 @@ -# Contenu Optimisé pour Landing Page ForTooling - -## 1. Hero Section - -### Titre Principal (Options) - -- "Gérez enfin vos équipements BTP sans vous ruiner" -- "Suivez tous vos équipements BTP pour moins de 2€ par jour" -- "Solution innovante pour localiser votre matériel de chantier" - -### Sous-titre - -"Solution simple par QR code - Mise en place en 48h - Sans engagement" - -### CTA Principal - -"ESSAYEZ GRATUITEMENT PENDANT 14 JOURS" - -### Mention Offre de Lancement - -"OFFRE SPÉCIALE LANCEMENT: -50% pour nos 20 premiers clients + implémentation offerte" - -### Visuel Stratégique - -Image/vidéo montrant la solution en action sur un chantier réel - -- Scan QR code sur un équipement -- Vue du dashboard avec localisation -- Interface mobile en situation de chantier - -## 2. Section Problème-Solution - -### Introduction - -"Les entreprises du BTP font face à des défis quotidiens dans la gestion de leur matériel. ForTooling apporte une solution simple et abordable." - -### Tableau Problème-Solution - -| PROBLÈME DANS LE SECTEUR | NOTRE SOLUTION | BÉNÉFICE POTENTIEL | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------- | -| Les entreprises BTP perdent en moyenne 15-20% de leurs équipements chaque année\* | Localisation instantanée par QR code et historique complet des mouvements | Réduction drastique des pertes et vols d'équipements | -| Jusqu'à 30 minutes par jour perdues à chercher du matériel sur les chantiers\* | Inventaire accessible en 3 clics avec localisation précise | Gain de temps quotidien pour vos équipes | -| Attribution floue menant à la déresponsabilisation | Traçabilité complète par utilisateur et notifications de non-retour | Responsabilisation des équipes et meilleur soin du matériel | -| Solutions traditionnelles complexes et onéreuses (5-10K€) | Prix fixe ultra-compétitif sans matériel coûteux | ROI rapide et budget maîtrisé | - -\*Selon étude sectorielle BTP Magazine 2023 - -## 3. Comment Ça Marche - -### Étape 1: ÉTIQUETEZ - -"Appliquez nos QR codes ultra-résistants sur vos équipements" - -- Étiquettes waterproof et résistantes aux chocs -- Installation en quelques secondes par équipement -- Compatible avec tous types d'outils et machines - -### Étape 2: SCANNEZ - -"Utilisez votre smartphone pour scanner lors des mouvements" - -- Scan rapide (3 secondes) lors des prises/retours -- Attribution à un utilisateur, projet ou emplacement -- Fonctionne même sans connexion internet sur le chantier - -### Étape 3: CONTRÔLEZ - -"Accédez à votre tableau de bord pour tout visualiser" - -- Vue d'ensemble de votre parc matériel -- Localisation actualisée de chaque équipement -- Historique complet des mouvements et utilisations -- Alertes automatiques pour équipements non retournés - -## 4. Avantages Clés ForTooling - -### Simplicité Extrême - -"Interface conçue pour être utilisée sur chantier, même avec des gants" - -- Prise en main en moins de 5 minutes -- Pas de formation complexe nécessaire -- Utilisable par tous vos collaborateurs - -### Prix Imbattable - -"Solution jusqu'à 70% moins chère que les alternatives traditionnelles" - -- À partir de 1,90€ par jour pour une PME -- Sans achat de matériel coûteux -- ROI généralement atteint dès le premier mois - -### Adapté au Terrain - -"Conçu pour résister aux conditions difficiles des chantiers" - -- Étiquettes ultra-résistantes (poussière, eau, chocs) -- Application mobile robuste et réactive -- Mode hors-ligne pour chantiers isolés - -### Déploiement Express - -"Opérationnel en 48h, sans perturber votre activité" - -- Assistance à la mise en place incluse -- Import facile de vos inventaires existants -- Support réactif par téléphone et email - -## 5. Offre Spéciale Lancement - -### Programme Pionnier ForTooling - -"Rejoignez nos premiers utilisateurs et bénéficiez d'avantages exclusifs" - -- **50% de réduction** sur l'abonnement première année -- **Mise en place et formation offertes** (valeur 500€) -- **Support prioritaire** avec accès direct à l'équipe -- **Influence sur les futures fonctionnalités** - -_Limité aux 20 premiers clients_ - -### CTA Principal Renforcé - -"RÉSERVEZ VOTRE PLACE DANS LE PROGRAMME PIONNIER" - -### Garantie Satisfaction - -"Essai 14 jours sans engagement - Satisfait ou remboursé 30 jours" - -## 6. FAQ Stratégique - -Questions traitant directement les objections potentielles: - -- **Q: Est-ce vraiment adapté à une entreprise qui débute avec la gestion numérique?** - R: Absolument! ForTooling a été conçu pour être aussi simple que possible, même pour les entreprises sans compétences techniques particulières. - -- **Q: Les QR codes résistent-ils vraiment aux conditions de chantier?** - R: Nos étiquettes sont spécialement conçues pour l'environnement BTP - résistantes à l'eau, la poussière, les UV et les chocs modérés. - -- **Q: Comment ForTooling se compare aux grandes solutions du marché?** - R: Nous offrons les fonctionnalités essentielles des grandes solutions (suivi, attribution, historique) mais à une fraction du prix et sans la complexité inutile. - -- **Q: Que se passe-t-il si nous n'avons pas de connexion sur le chantier?** - R: L'application fonctionne parfaitement hors-ligne et synchronise automatiquement les données dès qu'une connexion est disponible. - -- **Q: Combien de temps pour être opérationnel?** - R: La plupart de nos clients sont pleinement opérationnels en 24-48h, incluant l'étiquetage de leurs premiers équipements. - -## 7. CTA Final - -### Appel à l'action de clôture - -"Rejoignez les entreprises BTP qui transforment leur gestion de matériel" - -### Formulaire Simplifié - -- Email professionnel -- Numéro de téléphone -- Taille approximative du parc d'équipements - -### Bouton d'envoi - -"DÉMARRER MON ESSAI GRATUIT" - -### Réassurance finale - -"Sans engagement - Configuration en 48h - Support inclus" diff --git a/docs-and-prompts/market/pages-essentielles.md b/docs-and-prompts/market/pages-essentielles.md deleted file mode 100644 index 03a88c2..0000000 --- a/docs-and-prompts/market/pages-essentielles.md +++ /dev/null @@ -1,114 +0,0 @@ -# Pages Essentielles à Développer pour ForTooling - -## Page "Fonctionnalités" détaillée - -### Structure recommandée - -- Introduction avec bénéfices globaux -- Sections par fonctionnalité majeure avec captures d'écran -- Comparaison discrète avec solutions concurrentes -- Cas d'usage par fonctionnalité -- CTA: "Essayer gratuitement" + "Demander une démo" - -### Éléments clés à inclure - -- **Module Inventaire** - - - Catalogage complet des équipements - - Catégorisation flexible adaptée au BTP - - Informations techniques et commerciales - - Gestion des cycles de vie (acquisition → maintenance → retrait) - -- **Module Suivi QR/NFC** - - - Processus de scan rapide (3 secondes) - - Localisation instantanée des équipements - - Historique complet des mouvements - - Fonctionnement hors-ligne sur chantier - -- **Module Attribution** - - - Assignation aux utilisateurs/équipes - - Attribution à des projets/chantiers - - Gestion des dates de retour prévues - - Alertes de non-retour automatiques - -- **Module Reporting** - - - Dashboard personnalisable - - Statistiques d'utilisation et disponibilité - - Calcul ROI et économies réalisées - - Exports PDF/Excel des rapports clés - -- **Fonctionnalités spéciales BTP** - - Étiquettes ultra-résistantes (poussière, eau, UV) - - Interface utilisable avec gants de chantier - - Vocabulaire et catégories pré-configurés BTP - - Champs personnalisés adaptés au secteur - -## Page "Tarifs" transparente - -### Structure recommandée - -- Introduction sur approche tarifaire (transparence, simplicité) -- 3 plans avec options clairement définies -- Comparaison des fonctionnalités par plan -- FAQ spécifiques aux prix -- CTA: "Démarrer avec [plan]" + "Contact commercial" - -### Plans tarifaires - -1. **Plan Essentiel** - - - Pour TPE/artisans (1-5 utilisateurs) - - Jusqu'à 100 équipements - - Fonctionnalités de base - - Prix: [X]€/mois ou [Y]€/jour - -2. **Plan Business** - - - Pour PME (5-20 utilisateurs) - - Jusqu'à 500 équipements - - Fonctionnalités avancées + rapports - - Prix: [X]€/mois ou [Y]€/jour - -3. **Plan Enterprise** - - Pour grandes entreprises (20+ utilisateurs) - - Équipements illimités - - Toutes fonctionnalités + personnalisation - - Prix: [X]€/mois ou [Y]€/jour - -### Avantages tarifaires à mettre en avant - -- Pas de coût matériel supplémentaire -- Économies réalisées vs pertes actuelles -- Prix par jour (perception moins coûteuse) -- Engagement flexible ou remise annuelle -- Offre spéciale de lancement (-50% premiers clients) - -## Page "À propos / Notre histoire" - -### Structure recommandée - -- Histoire de création (problème observé → solution) -- Mission et vision (démocratiser la gestion d'équipements BTP) -- Équipe (même petite, montrer les visages) -- Valeurs (simplicité, accessibilité, innovation) -- Programme Pionnier mis en avant -- CTA: "Rejoindre l'aventure ForTooling" - -### Éléments narratifs à développer - -- Origine de l'idée (expérience terrain BTP, observation problématiques) -- Approche différenciatrice (simplicité vs solutions complexes existantes) -- Vision du futur de la gestion d'équipements -- Parcours de développement du produit -- Ambitions et roadmap produit à venir - -### Éléments de confiance - -- Expertise combinée BTP et technologie -- Approche centrée sur problématiques terrain -- Témoignages experts/consultants sectoriels -- Mentions médias/partenaires (si disponibles) -- Engagement qualité et support réactif diff --git a/docs-and-prompts/market/pages-seo-sectorielles.md b/docs-and-prompts/market/pages-seo-sectorielles.md deleted file mode 100644 index 3ca28a7..0000000 --- a/docs-and-prompts/market/pages-seo-sectorielles.md +++ /dev/null @@ -1,252 +0,0 @@ -# Pages Stratégiques SEO et Contenu Sectoriel - -## Pages sectorielles/cas d'usage - -Ces pages ciblent des sous-segments spécifiques avec un contenu optimisé pour le référencement et la conversion. - -### 1. "ForTooling pour la maçonnerie" - -#### Structure recommandée - -- Introduction aux défis spécifiques de gestion d'équipements en maçonnerie -- Statistiques sectorielles (pertes d'outils, temps de recherche) -- Fonctionnalités ForTooling adaptées à la maçonnerie -- Catégories d'équipements pré-configurées -- Cas d'usage concrets (chantier type) -- Bénéfices chiffrés spécifiques -- Témoignage expert/consultant secteur (si pas encore de client) -- CTA sectoriel - -#### Mots-clés à cibler - -- gestion équipement maçonnerie -- suivi matériel chantier construction -- localisation outils maçons -- QR code suivi bétonnière/coffrage -- gestion prêt matériel maçonnerie - -#### Équipements spécifiques à mentionner - -- Bétonnières et malaxeurs -- Échafaudages et étais -- Outillage manuel spécifique -- Coffrages et banches -- Équipements de mesure et niveau - -### 2. "ForTooling pour l'électricité/plomberie" - -#### Structure recommandée - -- Introduction aux problématiques des artisans multi-sites -- Coût des outils spécialisés et impact des pertes -- Fonctionnalités ForTooling pour interventions multiples -- Gestion des équipements techniques coûteux -- Attribution aux techniciens itinérants -- Suivi et maintenance des outils de mesure -- ROI calculé pour artisan type -- CTA adapté - -#### Mots-clés à cibler - -- gestion outillage électricien -- suivi matériel plomberie -- attribution équipement techniciens -- traçabilité outils électroportatifs -- inventaire camion artisan - -#### Équipements spécifiques à mentionner - -- Outillage électroportatif -- Appareils de mesure et test -- Échelles et accès en hauteur -- Équipements de sécurité -- Stock véhicules d'intervention - -### 3. "ForTooling pour les locations de matériel" - -#### Structure recommandée - -- Introduction aux défis de la location d'équipements -- Problématique des retours et suivi -- Fonctionnalités de gestion entrées/sorties -- Traçabilité complète et historique -- Suivi état et maintenance des équipements -- Intégration facturation et gestion client -- Avantages compétitifs pour loueurs -- CTA spécifique location - -#### Mots-clés à cibler - -- gestion parc location BTP -- suivi retour équipements loués -- QR code location matériel -- logiciel gestion entrées sorties matériel -- traçabilité équipements location - -#### Fonctionnalités spécifiques à mettre en avant - -- Check-in/check-out rapide -- Suivi état avant/après location -- Historique par client/équipement -- Alertes retards de retour -- Planning disponibilité matériel - -### 4. "ForTooling pour les chefs de chantier" - -#### Structure recommandée - -- Introduction aux défis quotidiens des chefs de chantier -- Impact sur la productivité et planning -- Fonctionnalités d'allocation des ressources -- Visibilité en temps réel sur les équipements -- Planification besoins matériels par phase -- Responsabilisation des équipes -- Gain de temps quotidien estimé -- CTA orienté productivité - -#### Mots-clés à cibler - -- gestion équipement chef chantier -- planification ressources matérielles BTP -- disponibilité outils chantier -- responsabilisation équipe matériel -- optimisation utilisation équipements construction - -#### Avantages spécifiques à mettre en avant - -- Réduction temps recherche matériel -- Anticipation besoins par phase chantier -- Suivi utilisation par équipe/ouvrier -- Réduction conflits attribution matériel -- Meilleure planification ressources - -## Blog et Ressources - -### Catégories d'articles à développer - -1. **Guides pratiques** - - - Conseils d'optimisation de gestion d'équipements - - Tutoriels étape par étape - - Check-lists et processus - -2. **Études sectorielles** - - - Statistiques et tendances BTP - - Benchmarks et comparatifs - - Analyse coûts cachés - -3. **Conseils et meilleures pratiques** - - - Organisation et méthodes - - Responsabilisation des équipes - - Optimisation des processus - -4. **Innovations technologiques** - - - Nouveautés dans le BTP - - Technologies de traçabilité - - Digitalisation des chantiers - -5. **Témoignages et cas d'usage** - - Interviews experts - - Retours d'expérience - - Études de cas - -### Articles initiaux prioritaires - -1. **"Les coûts cachés d'une mauvaise gestion d'équipements BTP"** - - - Chiffrer les pertes financières réelles - - Impact sur productivité et délais - - Coûts indirects (recherche, remplacement, conflits) - - Solution et calcul ROI - -2. **"Comment réduire de 70% vos pertes de matériel sur chantier"** - - - Statistiques pertes secteur BTP - - Causes principales identifiées - - Méthodologie de réduction en 5 étapes - - Technologies facilitantes - -3. **"QR codes vs RFID vs GPS: quelle technologie de suivi pour vos équipements?"** - - - Comparatif détaillé des technologies - - Avantages/inconvénients de chaque solution - - Critères de choix selon besoins - - Analyse coûts/bénéfices - -4. **"5 indicateurs clés pour évaluer l'efficacité de votre gestion de parc matériel"** - - - KPIs essentiels à suivre - - Méthodes de calcul et benchmarks - - Outils de mesure recommandés - - Plan d'amélioration continu - -5. **"Guide: Comment mettre en place un système de traçabilité en 1 semaine"** - - Planification et préparation - - Étapes jour par jour - - Ressources nécessaires - - Conseils pour adoption rapide - -## Centre de Ressources - -### Types de contenus à proposer - -- **Guides téléchargeables (PDF)** - - - Guides approfondis et bien structurés - - Design professionnel avec illustrations - - Contenu actionnable et pratique - -- **Templates et calculateurs (Excel)** - - - Outils prêts à l'emploi - - Formules et automatisations utiles - - Instructions d'utilisation claires - -- **Checklists imprimables** - - - Format synthétique et pratique - - Points essentiels à vérifier - - Personnalisables par l'utilisateur - -- **Vidéos tutoriels** - - - Courtes (3-5 minutes maximum) - - Démonstrations pas-à-pas - - Sous-titrées et bien structurées - -- **Webinaires enregistrés** - - Présentations thématiques (30-45 min) - - Q&A incluses - - Slides téléchargeables - -### Ressources initiales prioritaires - -1. **"Guide ultime de la gestion d'équipements BTP" (ebook)** - - - 15-20 pages approfondies - - Illustrations et schémas - - Conseils pratiques et méthodologie - - Études de cas et exemples - -2. **"Calculateur ROI ForTooling" (spreadsheet interactif)** - - - Calculateur d'économies personnalisé - - Projection sur 1, 2 et 3 ans - - Comparaison avant/après - - Graphiques automatisés - -3. **"Checklist: Préparer votre migration vers un système digital"** - - - Liste de contrôle pré-migration - - Étapes essentielles chronologiques - - Points de vigilance et conseils - - Format imprimable A4 - -4. **"10 astuces pour maximiser la durée de vie de vos équipements"** - - Guide pratique maintenance préventive - - Conseils stockage et manipulation - - Fréquences d'entretien recommandées - - Estimation économies réalisables diff --git a/docs-and-prompts/market/pages-support-conversion.md b/docs-and-prompts/market/pages-support-conversion.md deleted file mode 100644 index 080c3ed..0000000 --- a/docs-and-prompts/market/pages-support-conversion.md +++ /dev/null @@ -1,147 +0,0 @@ -# Pages de Support à la Conversion - -## Page "Comment ça marche" approfondie - -### Structure recommandée - -- Vidéo explicative (1-2 min) -- Processus détaillé en 5-7 étapes -- Zoom sur l'implémentation (48h) -- Témoignages d'experts sectoriels (si pas de clients, consultants BTP) -- FAQ spécifiques à l'implémentation -- CTA: "Voir une démo" + "Essai gratuit" - -### Processus à détailler - -1. **Inscription et configuration initiale** (15 min) - - - Création du compte entreprise - - Configuration des paramètres clés - - Personnalisation des catégories d'équipements - -2. **Import initial des équipements** (1-2h) - - - Upload de fichier Excel existant ou - - Saisie manuelle simplifiée ou - - Assistance à l'import par notre équipe - -3. **Étiquetage des équipements** (progressif) - - - Réception des QR codes résistants - - Application sur les équipements - - Scan initial de référencement - -4. **Formation des utilisateurs** (30 min) - - - Session de démonstration - - Guide pas-à-pas dans l'application - - Accès à des tutoriels vidéo - -5. **Déploiement terrain** (1-3 jours) - - - Premiers scans en conditions réelles - - Suivi des premières attributions - - Ajustement des processus si nécessaire - -6. **Optimisation continue** - - Analyse des premiers jours d'utilisation - - Recommandations personnalisées d'utilisation - - Ajout progressif d'équipements supplémentaires - -### Éléments visuels à inclure - -- Calendrier visuel du déploiement -- Screenshots étape par étape -- Exemples de QR codes et étiquettes -- Témoignages visuels de satisfaction - -## Page "FAQ" complète - -### Structure recommandée - -- Sections par thématique -- Questions organisées de générales à spécifiques -- Réponses concises mais complètes -- Liens vers pages détaillées -- CTA contextuel après chaque section - -### Catégories et questions essentielles - -#### Questions générales - -- Qu'est-ce que ForTooling exactement? -- Comment ForTooling se compare-t-il aux solutions existantes? -- Combien de temps pour être opérationnel? -- ForTooling est-il adapté à une petite entreprise? -- Puis-je essayer ForTooling avant de m'engager? - -#### Questions techniques - -- Les QR codes résistent-ils aux conditions de chantier? -- Que se passe-t-il si je n'ai pas de connexion sur le chantier? -- Quels appareils sont compatibles avec ForTooling? -- Les données sont-elles sécurisées? -- Puis-je exporter mes données facilement? - -#### Questions d'implémentation - -- Comment importer mon inventaire existant? -- Comment former mes équipes à l'utilisation? -- Combien de temps pour étiqueter tout mon matériel? -- Puis-je déployer progressivement la solution? -- Quel support recevrai-je pendant l'implémentation? - -#### Questions tarifaires - -- Y a-t-il des coûts cachés ou supplémentaires? -- Que comprend exactement chaque forfait? -- Puis-je changer de forfait en cours d'abonnement? -- Comment fonctionne la période d'essai? -- Offrez-vous des remises pour engagement annuel? - -#### Questions support et utilisation - -- Quel support est disponible en cas de problème? -- Proposez-vous des formations avancées? -- Comment suggérer de nouvelles fonctionnalités? -- Quelle est la disponibilité du service (uptime)? -- Comment contacter le support technique? - -## Page "Contact/Démo" - -### Structure recommandée - -- Options de contact (formulaire, email, téléphone) -- Planification de démo (calendrier Calendly) -- Processus de démonstration expliqué -- Formulaire contact intelligent (qualification leads) -- CTA secondaire: "Essai gratuit immédiat" - -### Formulaire de contact stratégique - -- Nom et prénom -- Email professionnel -- Téléphone (optionnel mais recommandé) -- Entreprise et fonction -- Taille de l'entreprise (dropdown) -- Nombre approximatif d'équipements à suivre -- Problématique principale (dropdown) -- Message personnalisé -- Préférence de contact (email, téléphone, visioconférence) - -### Section démo personnalisée - -- Titre: "Découvrez ForTooling en action sur vos cas d'usage" -- Explication: Démo personnalisée de 20 minutes -- Bénéfices: Focus sur vos besoins spécifiques -- Processus en 3 étapes (Prise de RDV → Préparation → Démonstration) -- Calendrier intégré pour réserver un créneau -- Témoignage sur qualité des démos - -### Informations de contact direct - -- Numéro de téléphone dédié -- Email de contact -- Horaires de disponibilité -- Temps de réponse moyen -- Chat en direct (si disponible) diff --git a/docs-and-prompts/market/pages-techniques-parcours.md b/docs-and-prompts/market/pages-techniques-parcours.md deleted file mode 100644 index 00a93bc..0000000 --- a/docs-and-prompts/market/pages-techniques-parcours.md +++ /dev/null @@ -1,189 +0,0 @@ -# Pages Techniques et Parcours Utilisateur - -## Pages Techniques et Légales - -### Pages Légales Essentielles - -1. **CGV/CGU** - - - Conditions claires et transparentes - - Langage accessible (éviter jargon juridique excessif) - - Sections bien structurées par thème - - Date de dernière mise à jour visible - -2. **Politique de confidentialité (RGPD)** - - - Données collectées et finalités - - Conservation et protection des données - - Droits des utilisateurs - - Utilisation des cookies - - Procédures de demande d'accès/suppression - -3. **Mentions légales** - - - Informations société - - Hébergement - - Directeur de publication - - Propriété intellectuelle - - Limitations de responsabilité - -4. **Conditions d'utilisation du service** - - Droits d'utilisation - - Restrictions d'usage - - Garanties et limites - - Résiliation et suspension - - Support et maintenance - -### Pages Techniques à Développer - -1. **Sécurité des données** - - - Architecture sécurisée - - Chiffrement et protection - - Sauvegardes et redondance - - Conformité RGPD - - Tests de sécurité réguliers - -2. **API et intégrations** - - - Documentation API (même basique pour le futur) - - Intégrations existantes ou prévues - - Procédure de demande d'accès API - - Cas d'usage d'intégration - - Support développeurs - -3. **Guide utilisateur/Centre d'aide** - - Navigation par rôle utilisateur - - Recherche intégrée - - Articles base de connaissances - - Vidéos tutoriels courtes - - FAQ technique détaillée - -## Optimisation des Parcours Utilisateur - -### Parcours d'Onboarding - -1. **Page "Premiers pas avec ForTooling"** - - - Guide visuel étape par étape - - Vidéo d'introduction (2-3 min) - - Checklist interactive de démarrage - - Jalons d'activation clairs - - Contact support dédié nouvel utilisateur - -2. **Guides spécifiques par profil utilisateur** - - - Pour administrateurs système - - Pour responsables matériel - - Pour utilisateurs terrain - - Pour chefs de chantier/projet - - Pour direction/décideurs (rapports) - -3. **Vidéos d'initiation courtes** - - - Série "Démarrer en 10 minutes" - - Tutoriels ciblés par fonctionnalité (1-2 min) - - Démos cas d'usage courants - - Astuces et raccourcis - - Questions fréquentes visuelles - -4. **Checklist de démarrage** - - Étapes essentielles séquentielles - - Indicateurs de progression - - Validation des étapes complétées - - Contenus d'aide contextuelle - - Célébration des succès d'activation - -### Programme Partenaires/Affiliés - -1. **Page Programme Ambassadeur** - - - Présentation des avantages - - Fonctionnement de la commission - - Témoignages partenaires (une fois existants) - - Outils marketing fournis - - FAQ programme partenaire - -2. **Commission référencement** - - - Grille de commission transparente - - Processus de tracking des leads - - Conditions de paiement - - Tableau de bord partenaire - - Support dédié partenaires - -3. **Processus d'inscription** - - - Critères d'éligibilité - - Formulaire de candidature - - Étapes de validation - - Formation initiale partenaire - - Kit de démarrage - -4. **Avantages et conditions** - - Avantages financiers détaillés - - Formations exclusives - - Accès anticipé nouvelles fonctionnalités - - Co-marketing opportunités - - Événements partenaires - -## Stratégie de Contenu par Phase - -### Phase 1 (Lancement - 3 premiers mois) - -1. Landing page principale -2. Pages Fonctionnalités, Tarifs, À propos -3. Page Comment ça marche -4. FAQ essentielle -5. Blog (3-5 articles initiaux) -6. Pages légales obligatoires - -**Priorité**: Conversion des premiers visiteurs en utilisateurs - -### Phase 2 (Développement - 3-6 mois) - -1. Pages sectorielles (2-3 premières) -2. Centre de ressources basique -3. Expansion du blog (1-2 articles/semaine) -4. Témoignages initiaux (dès premiers clients) -5. FAQ approfondie - -**Priorité**: SEO et création d'autorité dans le domaine - -### Phase 3 (Optimisation - 6-12 mois) - -1. Études de cas détaillées -2. Contenus avancés (webinaires, podcasts) -3. Pages partenaires et intégrations -4. Contenu généré par utilisateurs -5. Communauté utilisateurs - -**Priorité**: Rétention et expansion de l'écosystème - -## Recommandations pour Mise en Œuvre - -1. **Prioriser selon impact sur conversion**: - - - Landing page → Fonctionnalités → Tarifs → Comment ça marche - -2. **Créer une structure modulaire**: - - - Composants réutilisables (témoignages, CTA, avantages) - - Système de blocs cohérents - -3. **Maintenir cohérence visuelle et messagerie**: - - - Palette de couleurs consistante - - Mêmes messages clés sur toutes les pages - - Iconographie et illustrations harmonisées - -4. **Optimiser pour mobile en priorité**: - - - Interface simplifiée - - CTAs adaptés (plus grands sur mobile) - - Navigation intuitive - -5. **Intégrer mesure et analytics**: - - Événements de conversion sur chaque page - - Heatmaps sur pages critiques - - Tests A/B progressifs diff --git a/docs-and-prompts/market/strategie-marketing-fortooling.md b/docs-and-prompts/market/strategie-marketing-fortooling.md deleted file mode 100644 index 44765d4..0000000 --- a/docs-and-prompts/market/strategie-marketing-fortooling.md +++ /dev/null @@ -1,276 +0,0 @@ -# Stratégie Marketing et Tunnel de Conversion ForTooling - -## 1. Positionnement Stratégique - -### 1.1 Unique Selling Proposition (USP) - -"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." - -### 1.2 Points de différenciation clés - -- **Prix ultra-compétitif** (50-70% moins cher que les concurrents) -- **Zéro matériel coûteux** (utilisation de QR codes/NFC vs balises GPS onéreuses) -- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) -- **Mise en place en moins de 48h** (vs semaines pour solutions concurrentes) -- **ROI immédiat et mesurable** (diminution des pertes, gain de temps) - -### 1.3 Persona cibles prioritaires - -1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) -2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) -3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) - -## 2. Architecture du Tunnel de Conversion - -### 2.1 Étape 1: Attraction (Top du Funnel) - -- **SEO ciblé** sur requêtes problématiques ("perte matériel chantier", "gestion outillage BTP") -- **Google Ads** sur mots-clés transactionnels à fort intent -- **Posts LinkedIn** ciblant les décideurs BTP (format statistiques choc + solution) -- **Publicité dans médias spécialisés BTP** (print et digital) - -### 2.2 Étape 2: Intérêt (Landing Page) - -- **Hero section impactante**: - - - Headline: "Fini les pertes de matériel: suivez tous vos équipements BTP pour 1,90€ par jour" - - Sous-title: "Solution simple par QR code - Mise en place en 48h - Sans engagement" - - Démonstration vidéo courte (30s) montrant la simplicité d'utilisation - - CTA principal: "ESSAI GRATUIT 14 JOURS" (en orange, contrasté) - - Preuve sociale: "Déjà +3000 équipements suivis dans 47 entreprises BTP" - -- **Section problème-solution immédiate** (priorité #1): - | PROBLÈME | NOTRE SOLUTION | BÉNÉFICE CHIFFRÉ | - |----------|----------------|------------------| - | 15-20% des équipements perdus chaque année | Localisation instantanée par QR code | Économie de 5 000-15 000€/an | - | 30 min/jour perdues à chercher du matériel | Inventaire accessible en 3 clics | Gain de 125h/an/employé | - | Attribution floue et déresponsabilisation | Traçabilité complète par utilisateur | -70% d'équipements non retournés | - | Solutions concurrentes à 5-10K€ | Prix fixe ultra-compétitif | ROI dès le premier mois | - -### 2.3 Étape 3: Considération (Mid-Funnel) - -- **Démonstration du fonctionnement** (3 étapes ultra-simples): - - 1. **ÉTIQUETEZ** vos équipements avec nos QR codes ultra-résistants - 2. **SCANNEZ** pour attribuer ou déplacer (3 secondes par scan) - 3. **CONTRÔLEZ** votre parc complet depuis le dashboard - -- **Section témoignages** avec métriques précises: - - - "Nous avons réduit nos pertes d'équipements de 83% en 3 mois" - Martin D., Directeur, MTP Construction - - "Économie de 12 500€ la première année et gain de temps quotidien" - Sophie L., Resp. Logistique, BatiPro - - Inclure photos, logos d'entreprises et postes spécifiques - -- **Social proof renforcée**: - - Compteur en temps réel d'équipements suivis - - Logos clients (avec autorisations) - - Notation clients (4.8/5 basée sur X avis) - -### 2.4 Étape 4: Conversion (Bottom Funnel) - -- **Pricing stratégique**: - - - Afficher tarifs en "par jour" plutôt qu'en mensuel (perception de coût moindre) - - Proposer 3 formules avec celle du milieu pré-sélectionnée (technique d'ancrage) - - Comparer avec le "coût de ne rien faire" (pertes annuelles moyennes: 7500€) - - Garantie "satisfait ou remboursé 30 jours" (réduction du risque perçu) - -- **CTA d'essai gratuit omniprésent**: - - Formulaire d'inscription ultra-simplifié (email + téléphone uniquement) - - "Commencez en 2 minutes - Sans carte bancaire" - - Décompte de temps limité: "Offre spéciale: -20% les 3 premiers mois si vous vous inscrivez aujourd'hui" - -### 2.5 Étape 5: Onboarding (Post-conversion) - -- **Séquence email automatisée**: - - - J1: Guide de démarrage rapide + vidéo personnalisée - - J3: Check-in "Besoin d'aide?" + cas d'usage clés - - J7: Partage de succès clients similaires - - J10: Invitation démonstration personnalisée - - J12: Rappel fin d'essai + témoignages résultats - - J14: Offre spéciale première année + formulaire CB - -- **Relance téléphonique stratégique**: - - Appel à J5: "Comment se passe votre essai? Des questions?" - - Appel à J13: "Prêt à continuer? Offre spéciale réservée pour vous" - -## 3. Optimisation SEO Stratégique - -### 3.1 Mots-clés prioritaires - -- **Intention transactionnelle forte**: - - - "logiciel gestion équipement BTP" - - "suivi matériel chantier QR code" - - "solution traçabilité outils construction" - - "gestion inventaire entreprise BTP" - -- **Intention informationnelle** (content marketing): - - "comment réduire pertes matériel chantier" - - "coût perte équipement construction" - - "responsabilisation équipe BTP" - - "ROI gestion parc équipements" - -### 3.2 Structure de contenu SEO - -- **Pages de landing spécifiques par problématique**: - - - /reduction-pertes-materiels-chantier - - /suivi-outils-qr-code - - /gestion-attribution-equipements-btp - - /economie-gestion-materiel-construction - -- **Blog optimisé** (minimum 2 articles/mois): - - "Comment cette entreprise a économisé 15 000€ en réduisant ses pertes de matériel" - - "Guide: Calculez ce que vous coûtent vraiment vos pertes d'équipements" - - "5 techniques pour responsabiliser vos équipes sur le matériel" - - "Étude de cas: De l'Excel à ForTooling - Transformation digitale d'un parc matériel" - -### 3.3 Optimisations techniques - -- **Schema.org markup** pour: - - - Témoignages (Review Schema) - - Tarifs (Offer Schema) - - FAQ (FAQPage Schema) - - Organisation (Organization Schema) - -- **Core Web Vitals** optimisés pour mobile: - - LCP < 2.5s (images optimisées, serveur rapide) - - FID < 100ms (JavaScript non-bloquant) - - CLS < 0.1 (layout stable, fonts préchargées) - -## 4. Conversion Rate Optimization (CRO) - -### 4.1 Tests A/B prioritaires - -1. **Hero Section**: - - - Headline axé problème vs headline axé solution - - CTA "Essai gratuit" vs "Voir la démo en 2 min" - - Vidéo autoplay vs image statique - -2. **Formulaire de conversion**: - - - Minimal (email uniquement) vs standard (email + téléphone) - - Pop-up vs inline - - Avec/sans countdown timer - -3. **Preuve sociale**: - - Logos clients vs témoignages détaillés - - Statistiques chiffrées vs histoires de réussite - - Placement haut vs bas de page - -### 4.2 Micro-conversions à tracker - -- Pourcentage de scroll (≥70% = intent) -- Temps passé sur page (≥2min = intent) -- Clics sur témoignages (fort intent) -- Visionnage vidéo démo (fort intent) -- Ouverture FAQ (intent modéré) - -### 4.3 Objections à lever explicitement - -- **Objection prix**: "Plus abordable qu'un seul équipement perdu par mois" -- **Objection complexité**: "Prise en main en moins de 5 minutes, même sans compétence technique" -- **Objection temps**: "Déploiement en 48h sans perturber votre activité" -- **Objection internet**: "Fonctionne hors-ligne sur les chantiers isolés" -- **Objection engagement**: "Sans engagement - Résiliable à tout moment" - -## 5. Éléments Visuels Marketing Stratégiques - -### 5.1 Images à fort impact - -- **Avant/Après visuel**: Chaos d'équipements vs organisation parfaite -- **ROI visualisé**: Graphique économies réalisées vs coût solution -- **Contexte réel**: Photos sur chantiers authentiques, pas de stock photos -- **Process simplifié**: Infographie 3 étapes (étiqueter → scanner → contrôler) - -### 5.2 Vidéos persuasives - -- **Démo ultra-courte** (30s) en autoplay sans son: scan → dashboard → localisation -- **Témoignage client** (1min): problème → solution → résultats mesurables -- **Explication technique** (2min): pour rassurer décideurs techniques - -### 5.3 Confiance et crédibilité - -- **Badges sécurité/RGPD**: conformité, sécurité des données -- **Logos partenaires/clients**: reconnaissance par l'écosystème -- **Certifications**: labels qualité, innovation -- **Médias**: mentions presse spécialisée BTP - -## 6. Tactiques de Growth Hacking - -### 6.1 Acquisition non-conventionnelle - -- **Partenariats fournisseurs BTP**: offre groupée avec vendeurs d'équipements -- **Programme ambassadeur**: commission pour chaque entreprise référée -- **Webinaires ciblés**: "Comment réduire vos pertes d'équipements de 70% en 30 jours" -- **Défi gratuit**: "Testez pendant 14 jours et mesurez vos économies - Résultats garantis" - -### 6.2 Rétention optimisée - -- **Gamification**: score "d'efficacité matériel" comparé à la moyenne du secteur -- **Alertes ROI**: notifications des économies réalisées -- **Check-in trimestriel**: rapport personnalisé d'optimisation avec consultant -- **Communauté**: groupe privé d'échange entre responsables matériel - -### 6.3 Referral Engine - -- **Programme "Parrainez un artisan"**: 2 mois offerts pour chaque référence -- **Co-marketing**: témoignages clients en échange de visibilité -- **Contenu co-créé**: études de cas détaillées avec clients ambassadeurs - -## 7. Plan d'Implémentation Prioritaire - -### 7.1 Actions immédiates (J+0 à J+30) - -1. Refonte de la landing page avec structure de conversion optimisée -2. Mise en place des tunnels d'emails automatisés pré/post essai -3. Création de 3 témoignages clients détaillés (vidéo + texte) -4. Configuration tracking analytics conversion (objectifs GA4/Meta) -5. Lancement campagne Google Ads sur mots-clés prioritaires - -### 7.2 Seconde phase (J+30 à J+90) - -1. Développement de 5 articles de blog optimisés SEO -2. Création landing pages spécifiques par problématique -3. Mise en place programme de parrainage client -4. Lancement tests A/B principaux (headline, CTA, formulaire) -5. Développement automatisation relances essais gratuits - -### 7.3 KPIs critiques à suivre - -- Taux de conversion visiteur → essai gratuit (objectif: >5%) -- Taux de conversion essai → client payant (objectif: >30%) -- CAC (Coût d'Acquisition Client) (objectif: <3 mois de revenu) -- LTV (Lifetime Value) (objectif: >24 mois) -- Taux de churn mensuel (objectif: <3%) - -## 8. Messages Persuasifs Clés (Copywriting) - -### 8.1 Headlines A/B testés - -- "Stop aux 7500€ perdus chaque année en équipements égarés sur vos chantiers" -- "Suivez 100% de vos équipements BTP pour moins de 2€ par jour - Sans matériel coûteux" -- "Vos outils toujours localisés, vos équipes responsabilisées, votre budget préservé" -- "Cette solution QR code a permis à 47 entreprises BTP d'économiser 350 000€ de matériel" - -### 8.2 Éléments de friction à éliminer - -- Formulaire trop long (réduire au strict minimum) -- Jargon technique (simplifier le langage) -- Prix mensuel (préférer affichage quotidien ou annuel avec économies) -- Étapes multiples (réduire au maximum les clics vers conversion) - -### 8.3 Modificateurs de valeur perçue - -- Calcul personnalisé des économies potentielles -- Comparatif direct avec solutions concurrentes -- Démonstration du temps économisé (convertir en euros) -- Garantie "Satisfait ou Remboursé" proéminente - ---- - -**RAPPEL STRATÉGIQUE**: L'objectif principal n'est pas de "vendre" mais de convaincre d'essayer le produit gratuitement pendant 14 jours. La véritable conversion s'effectuera grâce à l'expérience produit elle-même et au processus d'onboarding soigneusement orchestré. diff --git a/docs-and-prompts/market/strategie-marketing-honnete.md b/docs-and-prompts/market/strategie-marketing-honnete.md deleted file mode 100644 index b99f3cb..0000000 --- a/docs-and-prompts/market/strategie-marketing-honnete.md +++ /dev/null @@ -1,85 +0,0 @@ -# Stratégie Marketing ForTooling - Phase de Lancement - -## Positionnement Stratégique pour une Nouvelle Solution - -### USP (Unique Selling Proposition) - -"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." - -### Points de différenciation clés (Factuel et vérifiable) - -- **Prix ultra-compétitif** (50-70% moins cher que les options établies) -- **Zéro matériel coûteux** (QR codes/NFC vs balises GPS onéreuses) -- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) -- **Mise en place en moins de 48h** (vs semaines pour solutions traditionnelles) -- **ROI rapide et mesurable** (diminution des pertes, gain de temps) - -### Persona cibles prioritaires - -1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) -2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) -3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) - -## Avantages du Statut de Nouvelle Entreprise - -### Transformer votre nouveauté en force - -- **Agilité et réactivité**: Adaptation rapide aux besoins spécifiques des premiers clients -- **Support personnalisé**: Attention particulière aux premiers utilisateurs -- **Influence sur le développement**: Participation à l'évolution du produit -- **Conditions préférentielles**: Avantages exclusifs pour les premiers adoptants - -### Programme "Pionniers ForTooling" - -- Réduction tarifaire substantielle pour les 20 premiers clients -- Support direct avec les fondateurs/développeurs -- Mise en avant future (avec accord) comme partenaires de la première heure -- Webinaires exclusifs et rencontres networking - -## Utilisation Stratégique des Données Sectorielles - -### Statistiques BTP exploitables (à sourcer) - -- Taux moyen de perte d'équipements dans le secteur (15-20% annuel) -- Coût moyen du remplacement de matériel (X€/an pour une PME moyenne) -- Temps quotidien perdu à rechercher du matériel (20-30 min/personne/jour) -- Impact financier des retards de chantier liés aux problèmes d'équipement - -### Calculs de ROI à mettre en avant - -- Simulateur d'économies basé sur taille de l'entreprise et parc d'équipement -- Coût réel des pertes vs investissement ForTooling -- Valorisation du temps gagné en recherche de matériel -- Économies liées à la prolongation de la durée de vie des équipements - -## Approche Content Marketing Adaptée - -### Contenu de valeur à créer en priorité - -- Guide: "Comment réduire les pertes de matériel sur vos chantiers" -- Ebook: "Les coûts cachés d'une mauvaise gestion d'équipements" -- Calculateur: "Estimez vos pertes annuelles d'équipements" -- Checklist: "10 bonnes pratiques pour augmenter la durée de vie de votre matériel" - -### Partenariats de contenu stratégiques - -- Collaboration avec médias BTP pour articles d'expertise -- Interviews de dirigeants et experts du secteur sur leurs problématiques -- Webinaires co-organisés avec fournisseurs d'équipements -- Présence sur salons professionnels avec offre spéciale salon - -## Stratégie d'Acquisition Adaptée aux Débuts - -### Canaux prioritaires - -- LinkedIn: ciblage précis des décideurs BTP -- Google Ads: mots-clés spécifiques à forte intention -- Démarchage direct: approche personnalisée des premiers clients -- Réseaux d'entrepreneurs et associations BTP - -### Tactiques d'acquisition créatives - -- "Test Challenge": Essai comparatif de ForTooling vs méthode actuelle pendant 14 jours -- Démonstrations in situ sur petits parcs d'équipements -- Programme parrainage avant même le lancement -- Offres groupées pour fédérations/groupements d'entreprises BTP diff --git a/docs-and-prompts/market/tunnel-conversion.md b/docs-and-prompts/market/tunnel-conversion.md deleted file mode 100644 index c322e61..0000000 --- a/docs-and-prompts/market/tunnel-conversion.md +++ /dev/null @@ -1,206 +0,0 @@ -# Tunnel de Conversion ForTooling - Phase de Lancement - -## 1. Structure du Tunnel de Vente - -### Phase 1: Attraction (Acquisition) - -- **Objectif**: Attirer des prospects qualifiés vers la landing page -- **Canaux prioritaires**: Google Ads, LinkedIn, référencement naturel -- **Message principal**: "Solution innovante pour suivre vos équipements BTP à prix mini" -- **KPI**: Coût par clic qualifié, taux de rebond initial - -### Phase 2: Intérêt (Landing Page) - -- **Objectif**: Capter l'attention et démontrer la compréhension du problème -- **Méthode**: Hero section impactante + section problème/solution -- **Message clé**: "Fini les pertes d'équipements et le temps perdu à chercher" -- **KPI**: Taux de scroll, temps sur page - -### Phase 3: Considération (Démonstration Valeur) - -- **Objectif**: Prouver l'efficacité et le ROI de la solution -- **Méthode**: Section "Comment ça marche" + avantages + simulateur d'économies -- **Message clé**: "Simple, rapide et jusqu'à 70% moins cher que les alternatives" -- **KPI**: Interactions avec simulateur, vidéos vues - -### Phase 4: Conversion (Essai Gratuit) - -- **Objectif**: Inciter à l'essai gratuit de 14 jours -- **Méthode**: Offre spéciale lancement + formulaire simplifié + garanties -- **Message clé**: "Essayez sans risque pendant 14 jours - Programme pionnier" -- **KPI**: Taux de conversion vers essai gratuit - -### Phase 5: Onboarding (Post-Conversion) - -- **Objectif**: Maximiser l'adoption et l'usage pendant l'essai -- **Méthode**: Email séquentiels + appel de bienvenue + guide démarrage -- **Message clé**: "Voyez des résultats concrets en seulement quelques jours" -- **KPI**: Taux d'activation, % utilisation des fonctionnalités clés - -### Phase 6: Conversion finale (Devenir client) - -- **Objectif**: Transformer l'essai en abonnement payant -- **Méthode**: Démonstration ROI déjà réalisé + offre spéciale fin d'essai -- **Message clé**: "Continuez à économiser avec notre offre spéciale pionnier" -- **KPI**: Taux de conversion essai → client payant - -## 2. Optimisation du Formulaire d'Essai Gratuit - -### Principes clés - -- **Minimalisme**: Demander uniquement l'information essentielle -- **Étapes**: Limiter à une seule étape si possible (max 2) -- **Valeur perçue**: Mettre en avant ce qu'ils obtiennent immédiatement -- **Réduction des frictions**: Éliminer tout obstacle à la complétion - -### Informations à collecter (par ordre de priorité) - -1. Email professionnel (obligatoire) -2. Numéro de téléphone (obligatoire - crucial pour suivi) -3. Nom de l'entreprise (obligatoire) -4. Taille approximative du parc d'équipements (optionnel mais utile) - -### Éléments de réassurance - -- "Sans carte bancaire" -- "Configuration en 48h" -- "Données sécurisées et confidentielles" -- "Annulation en 1 clic" - -## 3. Séquence Emails Post-Inscription - -### Email 1: Confirmation immédiate - -- **Objet**: "Bienvenue dans l'aventure ForTooling! Voici la suite..." -- **Contenu**: Confirmation + prochaines étapes + calendrier rendez-vous onboarding -- **CTA**: "Planifier mon appel de démarrage rapide (15min)" - -### Email 2: J+1 - Guide de démarrage - -- **Objet**: "Votre guide étape par étape pour démarrer avec ForTooling" -- **Contenu**: PDF guide démarrage + vidéo courte + FAQ initiale -- **CTA**: "Voir la vidéo de démarrage (3min)" - -### Email 3: J+3 - Première vérification - -- **Objet**: "Avez-vous rencontré des difficultés avec ForTooling?" -- **Contenu**: Check-in + astuces clés + proposition d'aide -- **CTA**: "Répondre pour obtenir de l'aide" ou "Tout va bien!" - -### Email 4: J+7 - Milestone et fonctionnalités avancées - -- **Objet**: "Découvrez ces 3 fonctionnalités qui vous feront gagner du temps" -- **Contenu**: Fonctionnalités avancées + témoignage + astuce pro -- **CTA**: "Activer ces fonctionnalités" - -### Email 5: J+10 - Partage de cas d'usage - -- **Objet**: "Comment les entreprises BTP utilisent ForTooling (exemples concrets)" -- **Contenu**: Cas d'usage + scénarios + bonnes pratiques -- **CTA**: "Appliquer ces méthodes à votre entreprise" - -### Email 6: J+12 - Préparation fin d'essai - -- **Objet**: "Votre essai ForTooling se termine dans 2 jours - Voici votre offre spéciale" -- **Contenu**: Récapitulatif valeur + offre exclusive + procédure simple -- **CTA**: "Activer mon offre spéciale pionniers (-50%)" - -### Email 7: J+14 - Dernier jour - -- **Objet**: "DERNIER JOUR - Votre décision concernant ForTooling" -- **Contenu**: Options disponibles + rappel bénéfices + témoignages -- **CTA**: "Continuer avec ForTooling" ou "Planifier un dernier appel" - -### Email 8: J+15 - Récupération (si pas converti) - -- **Objet**: "Nous respectons votre décision, mais avant de nous quitter..." -- **Contenu**: Sondage court + offre dernière chance + possibilité extension -- **CTA**: "Bénéficier d'une semaine supplémentaire d'essai" - -## 4. Script d'Appel de Bienvenue - -### Objectif de l'appel - -Établir une relation, comprendre les besoins spécifiques, assurer le bon démarrage - -### Introduction (1min) - -"Bonjour [Prénom], merci d'avoir démarré votre essai de ForTooling! Je m'appelle [Votre nom] et je suis là pour m'assurer que vous puissiez tirer le maximum de votre période d'essai. Avez-vous quelques minutes pour que nous parlions de vos besoins spécifiques?" - -### Questions clés (5min) - -1. "Pouvez-vous me parler brièvement des défis que vous rencontrez actuellement avec la gestion de vos équipements?" -2. "Environ combien d'équipements souhaitez-vous suivre avec ForTooling?" -3. "Avez-vous déjà utilisé une solution similaire par le passé?" -4. "Qu'est-ce qui vous a incité à essayer ForTooling spécifiquement?" - -### Présentation personnalisée (5min) - -"D'après ce que vous me dites, je pense que ces fonctionnalités spécifiques pourraient vous être particulièrement utiles..." (adapter selon réponses) - -### Plan de démarrage (3min) - -"Voici ce que je vous propose comme plan pour ces 14 jours d'essai: - -1. Aujourd'hui/demain: Configuration initiale de votre compte -2. D'ici la fin de semaine: Étiquetage de vos premiers équipements (10-20) -3. Début semaine prochaine: Formation rapide de vos équipes (15min max) -4. Milieu de semaine prochaine: Premier bilan d'utilisation avec moi - Cela vous semble-t-il réalisable?" - -### Conclusion et prochaines étapes (1min) - -"Super! Je vais vous envoyer un récapitulatif par email. N'hésitez pas à me contacter directement à ce numéro si vous avez la moindre question. Notre objectif est que vous puissiez voir des résultats concrets avant la fin de votre période d'essai." - -## 5. Stratégie de Relance Fin d'Essai - -### Principes - -- Approche consultative plutôt que pression commerciale -- Focus sur valeur déjà obtenue pendant l'essai -- Offre spéciale avec délai limité - -### Timing des relances - -- J-3: Email préparatoire -- J-1: Relance téléphonique -- J+0: Email "dernier jour" -- J+1: Appel de récupération si non converti - -### Script d'appel J-1 - -"Bonjour [Prénom], c'est [Votre nom] de ForTooling. Je vous appelle car votre période d'essai se termine demain, et je voulais faire un point avec vous: - -1. Comment s'est passée votre expérience jusqu'à présent? -2. Avez-vous pu observer des améliorations dans la gestion de vos équipements? -3. Y a-t-il des questions ou préoccupations qui pourraient vous empêcher de continuer? - -Comme vous faites partie de nos premiers utilisateurs, nous avons une offre spéciale "Pionnier": -50% sur votre abonnement première année, ce qui ramène le coût à seulement [X]€ par mois. - -Souhaitez-vous bénéficier de cette offre pour continuer avec ForTooling?" - -## 6. Tactiques de Réduction des Abandons - -### Identifiez les signes d'alerte précoces - -- Non-connexion après 3 jours -- Moins de 5 équipements enregistrés -- Absence de scans après configuration - -### Actions préventives - -- Email personnalisé: "Besoin d'aide pour démarrer?" -- Appel proactif: "Puis-je vous aider avec la mise en place?" -- Offre d'extension: "Besoin de plus de temps? Essayez 7 jours supplémentaires" - -### Incitatifs de rétention - -- Débloquer fonctionnalité premium pendant l'essai -- Offrir configuration gratuite des 20 premiers équipements -- Proposer session de formation équipe offerte - -### Feedback sur les abandons - -- Sondage court et simple -- Appel de suivi non-commercial -- Offre de retour facilitée (données conservées 30 jours) diff --git a/docs-and-prompts/stack-technique.md b/docs-and-prompts/stack-technique.md deleted file mode 100644 index 1e82217..0000000 --- a/docs-and-prompts/stack-technique.md +++ /dev/null @@ -1,150 +0,0 @@ -# Stack Technique Finale - Plateforme SaaS de Gestion d'Équipements NFC/QR - -## 1. Vue d'ensemble - -Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les technologies modernes du web pour offrir une solution robuste, performante et évolutive. L'architecture est conçue pour être hautement optimisée, sécurisée et facile à maintenir. - -## 2. Frontend - -### Framework & UI - -- **Next.js 15+** - Framework React avec App Router et Server Components -- **React 19+** - Bibliothèque UI pour construire des interfaces interactives -- **Tailwind CSS 4+** - Framework CSS utility-first pour le styling -- **shadcn/ui** - Composants UI réutilisables basés sur Radix UI -- **Lucide React** - Bibliothèque d'icônes SVG -- **Framer Motion** - Animations et transitions fluides -- **Rive** - Animations complexes et interactives - -### Gestion d'état client - -- **Zustand** - Gestion d'état global légère et simple - - Utilisé pour éviter le prop drilling - - Stockage des préférences utilisateur, thèmes, filtres - - État partagé entre composants distants - -### PWA & Mobile - -- **next-pwa** - Transforme l'application en Progressive Web App -- **WebNFC API** - Accès aux fonctionnalités NFC pour les appareils compatibles -- **QR Code fallback** - Solution alternative pour les appareils sans NFC - -### Qualité & Tests - -- **TypeScript** - Typage statique pour une meilleure qualité de code -- **ESLint/Prettier** - Linting et formatage de code -- **Vitest** - Tests unitaires rapides -- **Playwright** - Tests end-to-end - -## 3. Backend & API - -### API & Validation - -- **Next.js Server Actions** - Actions serveur typées et sécurisées - - Pattern de protection centralisé (HOF withProtection) - - Isolation multi-tenant intégrée -- **Zod** - Validation de schémas pour les données d'entrée -- **Tan stack Form** - Gestion de formulaires avec validation côté client - -### Backend - -- **Pockebase** - Backend as a service - -### Sécurité API - -- **Rate limiting** - Protection contre les abus -- **CORS** - Sécurité pour les requêtes cross-origin -- **Helmet** - Sécurisation des headers HTTP - -## 4. Services & Intégrations - -### Authentification & Paiements - -- **Clerk 6+** - Authentification complète et gestion des utilisateurs -- **Stripe** - Traitement des paiements et gestion des abonnements - -### Recherche & Stockage - -- **Algolia** - Recherche rapide et pertinente -- **Cloudflare R2** - Stockage d'objets compatible S3 - -### Communication & Notifications - -- **Resend** - Service d'emails transactionnels -- **Twilio** - SMS et notifications mobiles -- **Socket.io** - Communication temps réel pour le monitoring - -### Fonctionnalités spécifiques - -- **OpenStreetMap + Leaflet.js** - Cartographie et géolocalisation -- **React-PDF** - Génération de rapports PDF -- **SheetJS** - Export de données en format Excel -- **Temporal.io** - Orchestration de workflows et tâches asynchrones - -## 5. Infrastructure & DevOps - -### Déploiement & CI/CD - -- **Coolify** - Plateforme self-hosted pour le déploiement -- **Docker** - Conteneurisation des services -- **GitHub Actions** - Automatisation CI/CD - -### Monitoring & Observabilité - -- **Prometheus + Grafana** - Collecte et visualisation de métriques -- **Loki** - Agrégation et exploration de logs -- **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) -- **Umami** - Analytics respectueux de la vie privée - -### Sauvegarde & Restauration - -- **pgbackrest** - Solution de backup robuste pour PostgreSQL -- **pg_dump automatisé** - Sauvegardes programmées - -## 6. Architecture multi-tenant - -- Architecture à schéma unique avec discrimination par tenant_id -- Isolation des données par organisation au niveau des Server Actions -- Middleware de protection centralisé pour les vérifications d'accès -- Optimisation des requêtes grâce aux index sur tenant_id - -## 7. Intégration NFC/QR - -- Approche hybride WebNFC + QR Code -- Points de scan fixes (entrées/sorties) -- Options pour scanners Bluetooth dans les zones de forte utilisation - -## 8. Optimisations & Performance - -- **SEO** - Optimisation pour la partie publique (landing) - - Screaming Frog pour l'audit - - Lighthouse pour les bonnes pratiques -- **Web Vitals** - Suivi continu des métriques de performance -- **Unlighthouse/IBM checker** - Outils d'analyse supplémentaires - -## 9. Documentation - -- **Swagger/OpenAPI** - Documentation d'API auto-générée -- **Docusaurus** - Documentation utilisateur et technique - -## 10. Structure du projet - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` diff --git a/docs-and-prompts/technique-prompt-system.md b/docs-and-prompts/technique-prompt-system.md deleted file mode 100644 index 5d5a321..0000000 --- a/docs-and-prompts/technique-prompt-system.md +++ /dev/null @@ -1,146 +0,0 @@ -# Prompt Système pour Assistant de Développement SaaS - Plateforme de Gestion d'Équipements NFC/QR - -## 🎯 Contexte du Projet - -Tu es un assistant de développement expert spécialisé dans la création d'une plateforme SaaS de gestion d'équipements avec tracking NFC/QR. Ce système permet aux entreprises de suivre, attribuer et maintenir leur parc d'équipements via une interface moderne et des fonctionnalités avancées de scanning et de reporting. - -## 📋 Directives Générales - -- **Langue**: Toujours coder et commenter en anglais -- **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques -- **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown -- **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code -- **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée -- **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité - -## 🏗️ Stack Technique à Respecter - -### Frontend - -- **Framework**: Next.js 15+, React 19+ -- **Styling**: Tailwind CSS 4+, shadcn/ui -- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 -- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) -- **Forms**: Tan Stack Form + Zod pour la validation -- **Animations**: Framer Motion, Rive pour les animations complexes -- **UI**: Composants shadcn/ui, icônes Lucide React -- **Mobile**: next-pwa, WebNFC API, QR code fallback - -### Backend - -- **API**: Next.js Server Actions avec middleware de protection centralisé -- **Validation**: Zod pour la validation des données -- **ORM**: Prisma avec PostgreSQL -- **Authentification**: Clerk 6+ -- **Paiements**: Stripe -- **Recherche**: Algolia -- **Stockage**: Cloudflare R2 -- **Emails**: Resend -- **SMS**: Twilio -- **Temps réel**: Socket.io -- **Tâches asynchrones**: Temporal.io - -### DevOps & Sécurité - -- **Déploiement**: Coolify, Docker -- **CI/CD**: GitHub Actions -- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip -- **Analytics**: Umami -- **Sécurité API**: Rate limiting, CORS, Helmet - -## 11. Schéma / visualisation - -Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. -Si il y a besoin de schémas, il faut les les créer avec [Mermaid](https://mermaid-js.github.io/) et suivre les bonnes pratiques de ce langage. - -## 🖋️ Conventions de Code & Documentation - -### Structuration du Code - -- Architecture modulaire et maintenable -- Séparation claire des préoccupations (SoC) -- DRY (Don't Repeat Yourself) et SOLID principles -- Pattern par fonctionnalité plutôt que par type technique -- Centralisation des vérifications de sécurité et d'autorisation - -### Style de Code - -- **TypeScript**: Types stricts et exhaustifs -- **React**: Composants fonctionnels avec hooks -- **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) -- **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types -- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement - -### Documentation - -- **JSDoc** pour toutes les fonctions, hooks, et types complexes: - -```typescript -/** - * Fetches equipment data based on provided filters - * @param {EquipmentFilters} filters - The filters to apply to the query - * @param {QueryOptions} options - Optional query parameters - * @returns {Promise} Array of equipment matching filters - * @throws {ApiError} When the API request fails - */ -``` - -- **Commentaires de code**: Explique le "pourquoi", pas le "quoi" -- Ajoute des logs explicatifs aux endroits clés - -### Tests - -- Tests unitaires avec Vitest -- Tests end-to-end avec Playwright -- Privilégier les tests pour la logique métier critique - -## 📐 Structure de Projet Attendue - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` - -## 🤝 Collaboration Attendue - -- **Proactivité**: Anticipe les besoins et problèmes potentiels -- **Pédagogie**: Explique les concepts complexes et les choix d'architecture -- **Adaptabilité**: Ajuste-toi à mes besoins et préférences au fur et à mesure -- **Progressivité**: Commence par les fondamentaux puis avance vers des implémentations plus complexes -- **Optimisations**: Suggère des améliorations mais priorise la lisibilité et la maintenabilité - -## 🚨 Anti-patterns à Éviter - -- Ne pas utiliser de classes React (préférer les composants fonctionnels) -- Éviter les any/unknown en TypeScript si possible -- Ne pas réinventer ce qui existe déjà dans les bibliothèques choisies -- Éviter les dépendances inutiles ou redondantes -- Ne pas mélanger les styles (préférer Tailwind) -- Éviter d'exposer des données sensibles dans le frontend -- Ne pas dupliquer la logique d'authentification et de validation -- Éviter de créer des Server Actions sans utiliser le middleware de protection - -## 🔄 Processus de Travail - -1. Comprends d'abord mon besoin ou problème -2. Propose une approche structurée avec les technologies appropriées -3. Implémente en expliquant les choix techniques -4. Suggère des améliorations ou alternatives si pertinent -5. Offre des conseils pour les tests et la maintenance - -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. From 47779af89659e8d361beb2cb18c93c31289e826a Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:05:19 +0200 Subject: [PATCH 10/73] feat(docs): add detailed requirements and architecture - Create comprehensive specifications for a SaaS equipment management platform - Outline objectives, functional needs, and user management features - Include diagrams for data relationships and system architecture - Detail backend services and integration points with external APIs --- .cursor/md/rules-cahier-des-charges.md | 212 +++++++++++++++++++++++ .cursor/md/rules-diagram-mermaid.md | 98 +++++++++++ .cursor/md/rules-stack-technique.md | 197 +++++++++++++++++++++ .cursor/md/rules-technique-prompt.md | 129 ++++++++++++++ .cursor/rules/rules-stack-technique.mdc | 91 +++++++--- .cursor/rules/rules-technique-prompt.mdc | 26 +-- 6 files changed, 704 insertions(+), 49 deletions(-) create mode 100644 .cursor/md/rules-cahier-des-charges.md create mode 100644 .cursor/md/rules-diagram-mermaid.md create mode 100644 .cursor/md/rules-stack-technique.md create mode 100644 .cursor/md/rules-technique-prompt.md diff --git a/.cursor/md/rules-cahier-des-charges.md b/.cursor/md/rules-cahier-des-charges.md new file mode 100644 index 0000000..d8e6fa9 --- /dev/null +++ b/.cursor/md/rules-cahier-des-charges.md @@ -0,0 +1,212 @@ +--- +description: +globs: +alwaysApply: true +--- +## 1. Contexte et problématique générale + +### 1.1 Problématique adressée + +De nombreuses entreprises possèdent et gèrent un parc d'équipements qu'elles doivent suivre, attribuer et entretenir. Ces équipements peuvent représenter plusieurs dizaines à centaines d'articles différents (outils, matériel technique, appareils spécialisés, etc.). + +Les systèmes traditionnels de gestion présentent des lacunes importantes : + +- Suivi manuel chronophage et source d'erreurs +- Difficulté à localiser rapidement les équipements +- Absence d'historique fiable des mouvements et utilisations +- Complexité pour gérer les attributions +- Manque de visibilité globale sur l'état du parc +- Coût très elevé pour des balises gps précies (e.g hilti etc) +- Impossibilité d'appliquer ça sur des éléments autres + +## 2. Objectifs de la plateforme SaaS + +Développer une plateforme SaaS de gestion d'équipements qui permettra de : + +- Centraliser l'inventaire complet du parc matériel +- Suivre la localisation de chaque équipement en temps réel grâce à des étiquettes nfc/qr +- Gérer l'attribution des équipements aux utilisateurs et aux projets/emplacements +- Automatiser la détection des entrées/sorties d'équipements via des points de scan +- Conserver l'historique de tous les mouvements et utilisations +- Fournir des analyses et statistiques d'utilisation avancées +- Offrir une solution adaptable à différents secteurs d'activité + +## 3. Besoins fonctionnels détaillés + +### 3.1 Gestion multi-organisations + +- Support de plusieurs organisations clientes avec isolation complète des données +- Paramétrage par organisation (terminologie, champs personnalisés, flux de travail) +- Gestion des rôles et permissions par organisation + +### 3.2 Gestion des équipements + +- Inventaire complet avec informations détaillées : + - Référence unique et code NFC/qr associé + - Nom et description + - Date d'acquisition et valeur + - État et niveau d'usure + - Spécifications techniques (type, marque, modèle, etc.) + - Catégorie de rattachement + - Champs personnalisables selon le secteur d'activité +- Création, modification et suppression d'équipements +- Association d'un équipement à une catégorie spécifique +- Support pour documentation technique, photos et fichiers associés +- Gestion des maintenances préventives et curatives + +### 3.3 Suivi automatisé par NFC // ou SCAN QR Code + +- Intégration avec des étiquettes nfc/qr à faible coût / ou équivalent +- Points de scan aux entrées/sorties des zones de stockage +- Scan mobile via smartphones/tablettes pour vérification terrain +- Détection automatique des mouvements d'équipements +- Alertes en cas de sortie non autorisée + - mail + - sms + - alerte perso +- Cartographie des dernières localisations connues + +### 3.4 Gestion des affectations + +- Attribution d'équipements à : + - Un utilisateur/employé + - Un projet/chantier + - Un emplacement physique +- Enregistrement des dates de début et fin d'affectation +- Affectation groupée de plusieurs équipements simultanément +- Workflows d'approbation configurables +- Historique complet des affectations + +### 3.5 Gestion des utilisateurs + +- Enregistrement des informations sur les utilisateurs : + - Profil complet (nom, prénom, contact, etc.) + - Rôle et permissions dans le système + - Département/équipe de rattachement +- Suivi des équipements attribués à chaque utilisateur +- Gestion des accès par niveau de permission + +### 3.6 Gestion des projets/emplacements/chantiers + +- Structure flexible adaptable selon les besoins : + - Projets temporaires avec dates de début/fin + - Emplacements physiques permanents + - Zones géographiques +- Hiérarchisation possible (bâtiment > étage > pièce) +- Géolocalisation et cartographie +- Suivi des équipements affectés + +### 3.7 Catégorisation des équipements + +- Système de catégories et sous-catégories multiniveau +- Attributs spécifiques par catégorie d'équipement +- Système de préfixage automatique des références +- Organisation logique adaptée au secteur d'activité + +### 3.8 Analyses et statistiques avancées + +- Dashboard personnalisable avec indicateurs clés +- Rapports sur les taux d'utilisation des équipements +- Analyses prédictives pour planification des besoins +- Alertes sur équipements sous-utilisés ou sur-utilisés +- Statistiques par utilisateur, projet, catégorie et équipement +- Rapports exportables dans différents formats + +### 3.9 Intégration et API + +- API REST complète pour intégration avec d'autres systèmes +- Intégration possible avec des ERP, GMAO, ou logiciels comptables +- Export/import de données en différents formats +- Webhooks pour événements système + +## 4. Description fonctionnelle détaillée + +### 4.1 Structure générale + +- Interface responsive accessible sur tous supports +- Cinq modules principaux : Utilisateurs, Projets/Emplacements, Catégories, Équipements, Affectations +- Navigation intuitive avec accès contextuel aux fonctionnalités +- Dashboard personnalisable par type d'utilisateur + +### 4.2 Module de gestion des utilisateurs + +- Annuaire complet avec recherche avancée et filtres +- Gestion des profils avec historique d'activité +- Vue des équipements actuellement affectés +- Statistiques d'utilisation et de responsabilité matérielle +- Système de notification personnalisable + +### 4.3 Module de gestion des projets/emplacements + +- Structure adaptable selon le secteur d'activité +- Visualisation des équipements actuellement présents +- Timeline d'occupation des ressources +- Planification des besoins futurs +- Cartographie des emplacements physiques + +### 4.4 Module de gestion des catégories + +- Arborescence des catégories personnalisable +- Gestion des attributs spécifiques par catégorie +- Règles de nommage et d'attribution automatisées +- Templates pour accélérer la création d'équipements similaires +- Rapports analytiques par catégorie + +### 4.5 Module de gestion des équipements + +- Interface complète de gestion d'inventaire +- Fiche détaillée avec historique complet de chaque équipement +- Journal d'activité avec tous les mouvements et scans nfc/qr +- Suivi du cycle de vie (de l'acquisition à la mise au rebut) +- Planning de maintenance préventive +- Système d'alerte pour maintenance ou certification à renouveler + +### 4.6 Module de gestion des affectations + +- Processus guidé d'affectation avec validation +- Scan nfc/qr pour confirmation de prise en charge +- Vue calendaire des disponibilités +- Système de réservation anticipée +- Alertes de retour pour affectations arrivant à échéance +- Workflows configurables avec approbations multi-niveaux + +### 4.7 Fonctionnalités de recherche avancée + +- Recherche globale intelligente sur tous les critères +- Filtres contextuels et sauvegarde de recherches favorites +- Recherche par scan nfc/qr pour identification rapide +- Suggestions intelligentes basées sur l'historique + +### 4.8 Module d'administration et paramétrage + +- Configuration complète adaptée à chaque organisation +- Personnalisation de la terminologie et des champs +- Gestion des droits et rôles utilisateurs +- Audit logs pour toutes les actions système +- Paramétrage des notifications et alertes + +## 5. Interactions et automatisations + +### 5.1 Workflow de scan nfc/qr + +- Scan à l'entrée/sortie des zones de stockage +- Mise à jour automatique de la localisation +- Vérification de la légitimité du mouvement +- Création automatique d'affectation sur scan sortant +- Clôture automatique d'affectation sur scan entrant + +### 5.2 Interactions entre équipements + +- Gestion des relations parent/enfant entre équipements +- Suivi des assemblages/désassemblages +- Alertes sur incompatibilités potentielles +- Recommandations d'équipements complémentaires + +### 5.3 Automatisation des processus + +- Rappels automatiques pour retours d'équipements +- Alertes de maintenance basées sur l'utilisation réelle +- Détection d'anomalies dans les patterns d'utilisation +- Suggestions d'optimisation du parc + +Ce cahier des charges est destiné à servir de référence pour le développement d'une plateforme SaaS de gestion d'équipements adaptable à différents secteurs d'activité, avec un accent particulier sur l'automatisation via technologie nfc/qr et l'analyse avancée des données. diff --git a/.cursor/md/rules-diagram-mermaid.md b/.cursor/md/rules-diagram-mermaid.md new file mode 100644 index 0000000..f5a2f75 --- /dev/null +++ b/.cursor/md/rules-diagram-mermaid.md @@ -0,0 +1,98 @@ +--- +description: +globs: +alwaysApply: true +--- +erDiagram +Organization { + string id PK + string name + string email + string phone + string address + json settings + string clerkId + string stripeCustomerId + string subscriptionId + string subscriptionStatus + string priceId + date created + date updated +} + +User { + string id PK + string name + string email + string phone + string role + boolean isAdmin + boolean canLogin + string lastLogin + file avatar + boolean verified + boolean emailVisibility + string clerkId + date created + date updated +} + +Equipment { + string id PK + string organizationId FK + string name + string qrNfcCode + string tags + editor notes + date acquisitionDate + string parentEquipmentId FK + date created + date updated +} + +Project { + string id PK + string organizationId FK + string name + string address + editor notes + date startDate + date endDate + date created + date updated +} + +Assignment { + string id PK + string organizationId FK + string equipmentId FK + string assignedToUserId FK + string assignedToProjectId FK + date startDate + date endDate + editor notes + date created + date updated +} + +Image { + string id PK + string title + string alt + string caption + file image + date created + date updated +} + +Organization ||--o{ User : has +Organization ||--o{ Equipment : owns +Organization ||--o{ Project : manages +Organization ||--o{ Assignment : oversees + +User }o--o{ Assignment : "is assigned to" + +Equipment }o--o{ Assignment : "is assigned via" +Equipment }o--o{ Equipment : "parent/child" + +Project }o--o{ Assignment : includes diff --git a/.cursor/md/rules-stack-technique.md b/.cursor/md/rules-stack-technique.md new file mode 100644 index 0000000..ba2083e --- /dev/null +++ b/.cursor/md/rules-stack-technique.md @@ -0,0 +1,197 @@ +--- +description: +globs: +alwaysApply: true +--- +# Stack Technique Finale - Plateforme SaaS de Gestion d'Équipements NFC/QR + +## 1. Vue d'ensemble + +Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les technologies modernes du web pour offrir une solution robuste, performante et évolutive. L'architecture est conçue pour être hautement optimisée, sécurisée et facile à maintenir. + +## 2. Frontend + +### Framework & UI + +- **Next.js 15+** - Framework React avec App Router et Server Components +- **React 19+** - Bibliothèque UI pour construire des interfaces interactives +- **Tailwind CSS 4+** - Framework CSS utility-first pour le styling +- **shadcn/ui** - Composants UI réutilisables basés sur Radix UI +- **Lucide React** - Bibliothèque d'icônes SVG +- **Framer Motion** - Animations et transitions fluides +- **Rive** - Animations complexes et interactives + +### Gestion d'état client + +- **Zustand** - Gestion d'état global légère et simple + - Utilisé pour éviter le prop drilling + - Stockage des préférences utilisateur, thèmes, filtres + - État partagé entre composants distants + - On utilisera pas les React Context, mais bien Zustand quand on aura besoin de ce genre de système + +### PWA & Mobile + +- **next-pwa** - Transforme l'application en Progressive Web App +- **WebNFC API** - Accès aux fonctionnalités NFC pour les appareils compatibles +- **QR Code fallback** - Solution alternative pour les appareils sans NFC + +### Qualité & Tests + +- **TypeScript** - Typage statique pour une meilleure qualité de code +- **ESLint/Prettier** - Linting et formatage de code +- **Vitest** - Tests unitaires rapides +- **Playwright** - Tests end-to-end + +## 3. Backend & API + +### API & Validation + +- **Next.js Server Actions** - Actions serveur typées et sécurisées + - Pattern de protection centralisé (HOF withProtection) + - Isolation multi-tenant intégrée +- **Zod** - Validation de schémas pour les données d'entrée +- **Tan stack form** - Gestion de formulaires avec validation côté client + +### Backend + +- **PocketBase** - Backend as a service + - Services modulaires (baseService, equipmentService, etc.) + - Gestion d'authentification et permissions + - Stockage de données structurées + +### Sécurité API + +- **Rate limiting** - Protection contre les abus +- **CORS** - Sécurité pour les requêtes cross-origin +- **Helmet** - Sécurisation des headers HTTP + +## 4. Services & Intégrations + +### Authentification & Paiements + +- **Clerk 6+** - Authentification complète et gestion des utilisateurs +- **Stripe** - Traitement des paiements et gestion des abonnements + +### Recherche & Stockage + +- **Algolia** - Recherche rapide et pertinente +- **Cloudflare R2** - Stockage d'objets compatible S3 + +### Communication & Notifications + +- **Resend** - Service d'emails transactionnels +- **Twilio** - SMS et notifications mobiles +- **Socket.io** - Communication temps réel pour le monitoring + +### Fonctionnalités spécifiques + +- **OpenStreetMap + Leaflet.js** - Cartographie et géolocalisation +- **React-PDF** - Génération de rapports PDF +- **SheetJS** - Export de données en format Excel +- **Temporal.io** - Orchestration de workflows et tâches asynchrones + +## 5. Infrastructure & DevOps + +### Déploiement & CI/CD + +- **Coolify** - Plateforme self-hosted pour le déploiement +- **Docker** - Conteneurisation des services +- **GitHub Actions** - Automatisation CI/CD + +### Monitoring & Observabilité + +- **Prometheus + Grafana** - Collecte et visualisation de métriques +- **Loki** - Agrégation et exploration de logs +- **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) +- **Umami** - Analytics respectueux de la vie privée + +## 6. Architecture multi-tenant + +- Architecture à schéma unique avec discrimination par tenant_id +- Isolation des données par organisation au niveau des Server Actions +- Middleware de protection centralisé pour les vérifications d'accès +- Optimisation des requêtes avec PocketBase + +## 7. Intégration NFC/QR + +- Approche hybride WebNFC + QR Code +- Points de scan fixes (entrées/sorties) +- Options pour scanners Bluetooth dans les zones de forte utilisation + +## 8. Optimisations & Performance + +- **SEO** - Optimisation pour la partie publique (landing) + - Screaming Frog pour l'audit + - Lighthouse pour les bonnes pratiques +- **Web Vitals** - Suivi continu des métriques de performance +- **Unlighthouse/IBM checker** - Outils d'analyse supplémentaires + +## 9. Documentation + +- **Swagger/OpenAPI** - Documentation d'API auto-générée +- **Docusaurus** - Documentation utilisateur et technique + +## 10. Structure du projet +``` +src/ +├── app/ # Next.js App Router +│ ├── (application)/ # Application sécurisée +│ │ ├── (clerk)/ # Routes authentifiées par Clerk +│ │ └── app/ # Fonctionnalités principales de l'application +│ ├── (marketing)/ # Routes publiques (landing) +│ └── actions/ # Server Actions sécurisées +│ ├── equipment/ # Actions pour la gestion des équipements +│ └── services/ # Services d'accès aux données +│ └── pocketbase/ # Services PocketBase modulaires +├── components/ # Composants React partagés +│ ├── app/ # Composants spécifiques à l'application +│ ├── magicui/ # Composants UI avancés (animations, effets) +│ └── ui/ # Composants UI de base (shadcn) +├── hooks/ # Hooks React personnalisés +├── lib/ # Code utilitaire partagé +├── stores/ # Stores Zustand +└── types/ # Types TypeScript partagés +``` + + +## 11. Schéma d'Architecture Globale + +```mermaid +flowchart TB + subgraph Client["Client (Browser/Mobile)"] + UI["Next.js UI"] + ZustandStore["Zustand Store"] + TanStackForm["Tan Stack Form"] + NFC["NFC/QR Scanner"] + end + + subgraph ServerSide["Server Side (Next.js)"] + ServerActions["Server Actions"] + Middleware["Protection Middleware"] + ClerkAuth["Clerk Auth"] + end + + subgraph Services["External Services"] + PocketBase["PocketBase"] + Stripe["Stripe Payments"] + CloudflareR2["Cloudflare R2"] + Algolia["Algolia Search"] + Resend["Resend Email"] + Twilio["Twilio SMS"] + end + + UI <--> ZustandStore + UI <--> TanStackForm + UI <--> NFC + TanStackForm --> ServerActions + UI <--> ServerActions + ServerActions <--> Middleware + Middleware <--> ClerkAuth + ServerActions <--> PocketBase + ServerActions <--> Stripe + ServerActions <--> CloudflareR2 + ServerActions <--> Algolia + ServerActions <--> Resend + ServerActions <--> Twilio +``` + diff --git a/.cursor/md/rules-technique-prompt.md b/.cursor/md/rules-technique-prompt.md new file mode 100644 index 0000000..74f0931 --- /dev/null +++ b/.cursor/md/rules-technique-prompt.md @@ -0,0 +1,129 @@ +--- +description: +globs: +alwaysApply: true +--- +# Prompt Système pour Assistant de Développement SaaS - Plateforme de Gestion d'Équipements NFC/QR + +## 🎯 Contexte du Projet + +Tu es un assistant de développement expert spécialisé dans la création d'une plateforme SaaS de gestion d'équipements avec tracking NFC/QR. Ce système permet aux entreprises de suivre, attribuer et maintenir leur parc d'équipements via une interface moderne et des fonctionnalités avancées de scanning et de reporting. + +## 📋 Directives Générales + +- **Langue**: Toujours coder et commenter en anglais +- **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques +- **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown +- **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code +- **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée +- **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité + +## 🏗️ Stack Technique à Respecter + +### Frontend + +- **Framework**: Next.js 15+, React 19+ +- **Styling**: Tailwind CSS 4+, shadcn/ui +- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 +- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) +- **Forms**: Tan stack form + Zod pour la validation +- **Animations**: Framer Motion, Rive pour les animations complexes +- **UI**: Composants shadcn/ui, icônes Lucide React +- **Mobile**: next-pwa, WebNFC API, QR code fallback + +### Backend + +- **API**: Next.js Server Actions avec middleware de protection centralisé +- **Validation**: Zod pour la validation des données +- **Backend Service**: PocketBase +- **Authentification**: Clerk 6+ +- **Paiements**: Stripe +- **Recherche**: Algolia +- **Stockage**: Cloudflare R2 +- **Emails**: Resend +- **SMS**: Twilio +- **Temps réel**: Socket.io +- **Tâches asynchrones**: Temporal.io + +### DevOps & Sécurité + +- **Déploiement**: Coolify, Docker +- **CI/CD**: GitHub Actions +- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip +- **Analytics**: Umami +- **Sécurité API**: Rate limiting, CORS, Helmet + +## 11. Schéma / visualisation + +Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](mdc:../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. +Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/mermaid-js.github.io) et suivre les bonnes pratiques de ce langage. + +## 🖋️ Conventions de Code & Documentation + +### Structuration du Code + +- Architecture modulaire et maintenable +- Séparation claire des préoccupations (SoC) +- DRY (Don't Repeat Yourself) et SOLID principles +- Pattern par fonctionnalité plutôt que par type technique +- Centralisation des vérifications de sécurité et d'autorisation + +### Style de Code + +- **TypeScript**: Types stricts et exhaustifs +- **React**: Composants fonctionnels avec hooks +- **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) +- **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types +- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement + +### Documentation + +- **JSDoc** pour toutes les fonctions, hooks, et types complexes: + +```typescript +/** + * Fetches equipment data based on provided filters + * @param {EquipmentFilters} filters - The filters to apply to the query + * @param {QueryOptions} options - Optional query parameters + * @returns {Promise} Array of equipment matching filters + * @throws {ApiError} When the API request fails + */ +``` + +- **Commentaires de code**: Explique le "pourquoi", pas le "quoi" +- Ajoute des logs explicatifs aux endroits clés + +### Tests + +- Tests unitaires avec Vitest +- Tests end-to-end avec Playwright +- Privilégier les tests pour la logique métier critique + +## 🤝 Collaboration Attendue + +- **Proactivité**: Anticipe les besoins et problèmes potentiels +- **Pédagogie**: Explique les concepts complexes et les choix d'architecture +- **Adaptabilité**: Ajuste-toi à mes besoins et préférences au fur et à mesure +- **Progressivité**: Commence par les fondamentaux puis avance vers des implémentations plus complexes +- **Optimisations**: Suggère des améliorations mais priorise la lisibilité et la maintenabilité + +## 🚨 Anti-patterns à Éviter + +- Ne pas utiliser de classes React (préférer les composants fonctionnels) +- Éviter les any/unknown en TypeScript si possible +- Ne pas réinventer ce qui existe déjà dans les bibliothèques choisies +- Éviter les dépendances inutiles ou redondantes +- Ne pas mélanger les styles (préférer Tailwind) +- Éviter d'exposer des données sensibles dans le frontend +- Ne pas dupliquer la logique d'authentification et de validation +- Éviter de créer des Server Actions sans utiliser le middleware de protection + +## 🔄 Processus de Travail + +1. Comprends d'abord mon besoin ou problème +2. Propose une approche structurée avec les technologies appropriées +3. Implémente en expliquant les choix techniques +4. Suggère des améliorations ou alternatives si pertinent +5. Offre des conseils pour les tests et la maintenance + +Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. \ No newline at end of file diff --git a/.cursor/rules/rules-stack-technique.mdc b/.cursor/rules/rules-stack-technique.mdc index 07fead5..ba2083e 100644 --- a/.cursor/rules/rules-stack-technique.mdc +++ b/.cursor/rules/rules-stack-technique.mdc @@ -54,7 +54,10 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les ### Backend -- **Pockebase** - Backend as a service +- **PocketBase** - Backend as a service + - Services modulaires (baseService, equipmentService, etc.) + - Gestion d'authentification et permissions + - Stockage de données structurées ### Sécurité API @@ -102,17 +105,12 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les - **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) - **Umami** - Analytics respectueux de la vie privée -### Sauvegarde & Restauration - -- **pgbackrest** - Solution de backup robuste pour PostgreSQL -- **pg_dump automatisé** - Sauvegardes programmées - ## 6. Architecture multi-tenant - Architecture à schéma unique avec discrimination par tenant_id - Isolation des données par organisation au niveau des Server Actions - Middleware de protection centralisé pour les vérifications d'accès -- Optimisation des requêtes grâce aux index sur tenant_id +- Optimisation des requêtes avec PocketBase ## 7. Intégration NFC/QR @@ -133,24 +131,67 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les - **Swagger/OpenAPI** - Documentation d'API auto-générée - **Docusaurus** - Documentation utilisateur et technique -## 10. Structure du projet - +## 10. Structure du projet ``` src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés +├── app/ # Next.js App Router +│ ├── (application)/ # Application sécurisée +│ │ ├── (clerk)/ # Routes authentifiées par Clerk +│ │ └── app/ # Fonctionnalités principales de l'application +│ ├── (marketing)/ # Routes publiques (landing) +│ └── actions/ # Server Actions sécurisées +│ ├── equipment/ # Actions pour la gestion des équipements +│ └── services/ # Services d'accès aux données +│ └── pocketbase/ # Services PocketBase modulaires +├── components/ # Composants React partagés +│ ├── app/ # Composants spécifiques à l'application +│ ├── magicui/ # Composants UI avancés (animations, effets) +│ └── ui/ # Composants UI de base (shadcn) +├── hooks/ # Hooks React personnalisés +├── lib/ # Code utilitaire partagé +├── stores/ # Stores Zustand +└── types/ # Types TypeScript partagés ``` + + +## 11. Schéma d'Architecture Globale + +```mermaid +flowchart TB + subgraph Client["Client (Browser/Mobile)"] + UI["Next.js UI"] + ZustandStore["Zustand Store"] + TanStackForm["Tan Stack Form"] + NFC["NFC/QR Scanner"] + end + + subgraph ServerSide["Server Side (Next.js)"] + ServerActions["Server Actions"] + Middleware["Protection Middleware"] + ClerkAuth["Clerk Auth"] + end + + subgraph Services["External Services"] + PocketBase["PocketBase"] + Stripe["Stripe Payments"] + CloudflareR2["Cloudflare R2"] + Algolia["Algolia Search"] + Resend["Resend Email"] + Twilio["Twilio SMS"] + end + + UI <--> ZustandStore + UI <--> TanStackForm + UI <--> NFC + TanStackForm --> ServerActions + UI <--> ServerActions + ServerActions <--> Middleware + Middleware <--> ClerkAuth + ServerActions <--> PocketBase + ServerActions <--> Stripe + ServerActions <--> CloudflareR2 + ServerActions <--> Algolia + ServerActions <--> Resend + ServerActions <--> Twilio +``` + diff --git a/.cursor/rules/rules-technique-prompt.mdc b/.cursor/rules/rules-technique-prompt.mdc index 5a305f9..74f0931 100644 --- a/.cursor/rules/rules-technique-prompt.mdc +++ b/.cursor/rules/rules-technique-prompt.mdc @@ -35,7 +35,7 @@ Tu es un assistant de développement expert spécialisé dans la création d'une - **API**: Next.js Server Actions avec middleware de protection centralisé - **Validation**: Zod pour la validation des données -- **ORM**: Prisma avec PostgreSQL +- **Backend Service**: PocketBase - **Authentification**: Clerk 6+ - **Paiements**: Stripe - **Recherche**: Algolia @@ -99,28 +99,6 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - Tests end-to-end avec Playwright - Privilégier les tests pour la logique métier critique -## 📐 Structure de Projet Attendue - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` - ## 🤝 Collaboration Attendue - **Proactivité**: Anticipe les besoins et problèmes potentiels @@ -148,4 +126,4 @@ src/ 4. Suggère des améliorations ou alternatives si pertinent 5. Offre des conseils pour les tests et la maintenance -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. +Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. \ No newline at end of file From 2bdf4e1ce345ecbac20c79d9279d807dd68afd65 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:07:12 +0200 Subject: [PATCH 11/73] feat(core): add example environment configuration - Create a template for environment variables - Include keys for Clerk authentication - Add placeholders for API tokens and URLs - Facilitate easier setup for new developers --- env.exemple | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 env.exemple diff --git a/env.exemple b/env.exemple new file mode 100644 index 0000000..c9ffcb4 --- /dev/null +++ b/env.exemple @@ -0,0 +1,8 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=-- +CLERK_SECRET_KEY=-- +NEXT_PUBLIC_CLERK_SIGN_IN_URL=--/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=--/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=--/app + +PB_TOKEN_API_ADMIN=--- +PB_API_URL=--- \ No newline at end of file From d6e604f4731650084b4abc412592490c0700c009 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:07:27 +0200 Subject: [PATCH 12/73] chore(env): rename env.exemple to env.example - Standardize the naming convention for environment file - Ensure consistency across documentation and code references --- env.exemple => env.example | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename env.exemple => env.example (100%) diff --git a/env.exemple b/env.example similarity index 100% rename from env.exemple rename to env.example From 843623ec55baf34ba3f3493d2bffe7c12edb5964 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:29:12 +0200 Subject: [PATCH 13/73] feat(sync): implement secure caching and data synchronization - Add a secure caching service to isolate user data - Implement methods for syncing users and organizations with PocketBase - Create middleware to ensure data consistency during requests - Introduce reconciliation endpoints for admin-triggered syncs - Integrate webhook handling for Clerk events to keep databases in sync - Enhance error handling and logging throughout the synchronization process --- bun.lock | 27 ++ package.json | 1 + .../services/clerk-sync/cacheService.ts | 150 ++++++++++++ .../services/clerk-sync/onBoardingHelper.ts | 137 +++++++++++ .../services/clerk-sync/reconciliation.ts | 231 ++++++++++++++++++ .../services/clerk-sync/syncMiddleware.ts | 129 ++++++++++ .../services/clerk-sync/syncService.ts | 222 +++++++++++++++++ .../webhook/clerk/admin/reconcile/route.ts | 84 +++++++ src/app/api/webhook/clerk/route.ts | 121 +++++++++ src/middleware.ts | 24 +- 10 files changed, 1125 insertions(+), 1 deletion(-) create mode 100644 src/app/actions/services/clerk-sync/cacheService.ts create mode 100644 src/app/actions/services/clerk-sync/onBoardingHelper.ts create mode 100644 src/app/actions/services/clerk-sync/reconciliation.ts create mode 100644 src/app/actions/services/clerk-sync/syncMiddleware.ts create mode 100644 src/app/actions/services/clerk-sync/syncService.ts create mode 100644 src/app/api/webhook/clerk/admin/reconcile/route.ts create mode 100644 src/app/api/webhook/clerk/route.ts diff --git a/bun.lock b/bun.lock index 3968d72..78f6331 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", + "svix": "^1.62.0", "tailwind-merge": "3.0.2", "tw-animate-css": "1.2.5", "zod": "3.24.2", @@ -252,6 +253,8 @@ "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -458,6 +461,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -510,6 +515,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], @@ -732,6 +739,8 @@ "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], @@ -792,6 +801,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], @@ -812,6 +823,8 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -890,6 +903,10 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svix": ["svix@1.62.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "svix-fetch": "^3.0.0", "url-parse": "^1.5.10" } }, "sha512-Ia1s78JVcK0SXEzULNln4Vqi8LN3l+9rEs7d10XoOtg1c/dY2r59W4qRwd77BVbstW2v3HmsSqXkeZ6eZktnhA=="], + + "svix-fetch": ["svix-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw=="], + "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], "synckit": ["synckit@0.10.3", "", { "dependencies": { "@pkgr/core": "^0.2.0", "tslib": "^2.8.1" } }, "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ=="], @@ -908,6 +925,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "ts-api-utils": ["ts-api-utils@2.0.1", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -940,12 +959,20 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], diff --git a/package.json b/package.json index 46f12d3..009ec41 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", + "svix": "^1.62.0", "tailwind-merge": "3.0.2", "tw-animate-css": "1.2.5", "zod": "3.24.2", diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts new file mode 100644 index 0000000..c2761a7 --- /dev/null +++ b/src/app/actions/services/clerk-sync/cacheService.ts @@ -0,0 +1,150 @@ +// src/app/actions/services/clerk-sync/cacheService.ts +import crypto from 'crypto' + +/** + * Interface for cache entries with security features + */ +interface SecureCacheEntry { + data: T + timestamp: number + userId: string + hash: string +} + +/** + * Secure caching service with user-specific isolation and integrity validation + * Implements security best practices to prevent cache manipulation attacks + */ +class SecureCache { + private cache: Map> + private secretKey: string + + constructor() { + this.cache = new Map() + + // Use the environment secret or generate a random one per instance + // This makes cache manipulation attacks significantly harder + this.secretKey = + process.env.CACHE_SECRET || crypto.randomBytes(32).toString('hex') + } + + /** + * Creates a cryptographic hash to verify data integrity + * + * @param data The data to hash + * @param userId The user ID to include in the hash + * @returns The generated hash + */ + private createHash(data: any, userId: string): string { + const content = JSON.stringify(data) + userId + this.secretKey + return crypto.createHash('sha256').update(content).digest('hex') + } + + /** + * Stores a value in the cache with security validation + * + * @param key The cache key + * @param value The value to store + * @param userId The user ID for isolation and validation + * @param ttl Optional TTL in milliseconds (defaults to 2 minutes) + */ + set( + key: string, + value: any, + userId: string, + ttl: number = 2 * 60 * 1000 + ): void { + // Create an integrity hash that binds the data to this specific user + const integrityHash = this.createHash(value, userId) + + this.cache.set(key, { + data: value, + hash: integrityHash, + timestamp: Date.now(), + userId, + }) + + // Set automatic expiration for this entry + if (ttl > 0) { + setTimeout(() => { + this.cache.delete(key) + }, ttl) + } + } + + /** + * Retrieves a value from the cache with security checks + * + * @param key The cache key + * @param userId The user ID for isolation and validation + * @param maxAge Optional maximum age in milliseconds + * @returns The cached value or null if not found/invalid + */ + get(key: string, userId: string, maxAge: number = 2 * 60 * 1000): any | null { + const entry = this.cache.get(key) + + // No entry found + if (!entry) { + return null + } + + // Check if entry has expired + if (maxAge > 0 && Date.now() - entry.timestamp > maxAge) { + this.cache.delete(key) + return null + } + + // Enforce user isolation - users can only access their own cache entries + if (entry.userId !== userId) { + return null + } + + // Verify data integrity using the hash + const expectedHash = this.createHash(entry.data, userId) + if (entry.hash !== expectedHash) { + // Hash mismatch indicates potential tampering - remove the entry + this.cache.delete(key) + console.warn(`Cache integrity violation detected for key: ${key}`) + return null + } + + return entry.data + } + + /** + * Invalidates a cache entry + * + * @param key The cache key to invalidate + * @param userId Optional user ID for additional security + */ + invalidate(key: string, userId?: string): void { + // If userId is provided, only allow invalidation of that user's entries + if (userId) { + const entry = this.cache.get(key) + if (entry && entry.userId === userId) { + this.cache.delete(key) + } + } else { + this.cache.delete(key) + } + } + + /** + * Clears all cache entries - use with caution + */ + clear(): void { + this.cache.clear() + } + + /** + * Gets the current size of the cache + * + * @returns Number of entries in the cache + */ + size(): number { + return this.cache.size + } +} + +// Singleton instance for app-wide use +export const secureCache = new SecureCache() diff --git a/src/app/actions/services/clerk-sync/onBoardingHelper.ts b/src/app/actions/services/clerk-sync/onBoardingHelper.ts new file mode 100644 index 0000000..01bf276 --- /dev/null +++ b/src/app/actions/services/clerk-sync/onBoardingHelper.ts @@ -0,0 +1,137 @@ +import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +// src/app/actions/services/clerk-sync/onboardingHelpers.ts +import { auth, clerkClient } from '@clerk/nextjs/server' + +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganization, +} from './syncService' + +/** + * Imports an organization after it's created during onboarding + * This function should be called after organization creation in Clerk + * + * @param clerkOrgId The Clerk organization ID to import + * @returns The imported organization data + */ +export async function importOrganizationAfterCreation(clerkOrgId: string) { + 'use server' + + try { + const { userId } = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + // Get the organization data from Clerk + const clerkClientInstance = await clerkClient() + const clerkOrg = + await clerkClientInstance.organizations.getOrganization(clerkOrgId) + + // Sync the organization to PocketBase + const organization = await syncOrganizationToPocketBase(clerkOrg) + + // Get the user from Clerk + const clerkUser = await clerkClientInstance.users.getUser(userId) + + // Sync the user to PocketBase + const user = await syncUserToPocketBase(clerkUser) + + // Create the membership data for the link + const membershipData = { + organization: { id: clerkOrgId }, + public_user_data: { user_id: userId }, + role: 'admin', // During onboarding, the creator is always an admin + } + + // Link the user to the organization + await linkUserToOrganization(membershipData) + + return { + organization, + status: 'success', + user, + } + } catch (error) { + console.error('Error importing organization after creation:', error) + throw error + } +} + +/** + * Updates user metadata in Clerk and syncs to PocketBase + * Useful for onboarding completion + * + * @param metadata The metadata to set for the user + * @returns Success status + */ +export async function updateUserMetadataAndSync(metadata: Record) { + 'use server' + + try { + const { userId } = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + // Update the metadata in Clerk + const clerkClientInstance = await clerkClient() + await clerkClientInstance.users.updateUserMetadata(userId, { + publicMetadata: metadata, + }) + + // Get the updated user + const clerkUser = await clerkClientInstance.users.getUser(userId) + + // Sync the updated user to PocketBase + await syncUserToPocketBase(clerkUser) + + return { status: 'success' } + } catch (error) { + console.error('Error updating user metadata and syncing:', error) + throw error + } +} + +/** + * Updates organization metadata in Clerk and syncs to PocketBase + * + * @param orgId The Clerk organization ID + * @param metadata The metadata to set for the organization + * @returns Success status + */ +export async function updateOrgMetadataAndSync( + orgId: string, + metadata: Record +) { + 'use server' + + try { + const { userId } = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + // Update the metadata in Clerk + const clerkClientInstance = await clerkClient() + await clerkClientInstance.organizations.updateOrganizationMetadata(orgId, { + publicMetadata: metadata, + }) + + // Get the updated organization + const clerkOrg = + await clerkClientInstance.organizations.getOrganization(orgId) + + // Sync the updated organization to PocketBase + await syncOrganizationToPocketBase(clerkOrg) + + return { status: 'success' } + } catch (error) { + console.error('Error updating organization metadata and syncing:', error) + throw error + } +} diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts new file mode 100644 index 0000000..3d68924 --- /dev/null +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -0,0 +1,231 @@ +// src/app/actions/services/clerk-sync/reconciliation.ts +import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +import { clerkClient } from '@clerk/nextjs' + +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganization, +} from './syncService' + +/** + * Full reconciliation between Clerk and PocketBase + * This script should be run periodically to ensure data consistency + * Could be triggered by a cron job or scheduled task + */ +export async function runFullReconciliation() { + console.log( + 'Starting full Clerk-PocketBase reconciliation:', + new Date().toISOString() + ) + + try { + const startTime = Date.now() + + // Get all users and organizations from Clerk + const clerkClientInstance = await clerkClient() + const clerkUsers = await clerkClientInstance.users.getUserList() + const clerkOrganizations = + await clerkClientInstance.organizations.getOrganizationList() + + console.log( + `Found ${clerkUsers.length} users and ${clerkOrganizations.length} organizations in Clerk` + ) + + // Sync all organizations first + console.log('Syncing organizations...') + const orgResults = await Promise.allSettled( + clerkOrganizations.map(org => syncOrganizationToPocketBase(org)) + ) + + const successfulOrgs = orgResults.filter( + result => result.status === 'fulfilled' + ).length + console.log( + `Successfully synced ${successfulOrgs}/${clerkOrganizations.length} organizations` + ) + + // Sync all users + console.log('Syncing users...') + const userResults = await Promise.allSettled( + clerkUsers.map(user => syncUserToPocketBase(user)) + ) + + const successfulUsers = userResults.filter( + result => result.status === 'fulfilled' + ).length + console.log( + `Successfully synced ${successfulUsers}/${clerkUsers.length} users` + ) + + // Sync organization memberships + console.log('Syncing organization memberships...') + let membershipCount = 0 + + for (const org of clerkOrganizations) { + try { + const memberships = + await clerkClientInstance.organizations.getOrganizationMembershipList( + { + organizationId: org.id, + } + ) + + for (const membership of memberships) { + try { + const membershipData = { + organization: { id: org.id }, + public_user_data: { user_id: membership.publicUserData.userId }, + role: membership.role, + } + + await linkUserToOrganization(membershipData) + membershipCount++ + } catch (error) { + console.error( + `Error syncing membership for user ${membership.publicUserData.userId} in org ${org.id}:`, + error + ) + } + } + } catch (error) { + console.error( + `Error fetching memberships for organization ${org.id}:`, + error + ) + } + } + + console.log( + `Successfully synced ${membershipCount} organization memberships` + ) + + // Done + const totalTime = (Date.now() - startTime) / 1000 + console.log(`Reconciliation completed in ${totalTime.toFixed(2)} seconds`) + + return { + memberships: membershipCount, + organizations: { + failed: clerkOrganizations.length - successfulOrgs, + total: clerkOrganizations.length, + }, + status: 'success', + timeTaken: totalTime, + users: { + failed: clerkUsers.length - successfulUsers, + total: clerkUsers.length, + }, + } + } catch (error) { + console.error('Reconciliation failed:', error) + throw error + } +} + +/** + * Reconcile a specific user by checking and updating their data + * Useful for targeted fixes or individual user troubleshooting + * + * @param clerkUserId The Clerk user ID to reconcile + * @returns The reconciliation result + */ +export async function reconcileSpecificUser(clerkUserId: string) { + try { + console.log(`Starting reconciliation for user ${clerkUserId}`) + + // Get user data from Clerk + const clerkClientInstance = await clerkClient() + const clerkUser = await clerkClientInstance.users.getUser(clerkUserId) + + // Sync user to PocketBase + await syncUserToPocketBase(clerkUser) + + // Find all organizations this user belongs to + const memberships = + await clerkClientInstance.users.getOrganizationMembershipList({ + userId: clerkUserId, + }) + + // Sync each organization and membership + for (const membership of memberships) { + const orgId = membership.organization.id + + // Sync the organization + const clerkOrg = + await clerkClientInstance.organizations.getOrganization(orgId) + await syncOrganizationToPocketBase(clerkOrg) + + // Sync the membership + const membershipData = { + organization: { id: orgId }, + public_user_data: { user_id: clerkUserId }, + role: membership.role, + } + + await linkUserToOrganization(membershipData) + } + + return { + memberships: memberships.length, + status: 'success', + userId: clerkUserId, + } + } catch (error) { + console.error(`Error reconciling user ${clerkUserId}:`, error) + throw error + } +} + +/** + * Reconcile a specific organization and all its members + * + * @param clerkOrgId The Clerk organization ID to reconcile + * @returns The reconciliation result + */ +export async function reconcileSpecificOrganization(clerkOrgId: string) { + try { + console.log(`Starting reconciliation for organization ${clerkOrgId}`) + + // Get organization data from Clerk + const clerkClientInstance = await clerkClient() + const clerkOrg = + await clerkClientInstance.organizations.getOrganization(clerkOrgId) + + // Sync organization to PocketBase + await syncOrganizationToPocketBase(clerkOrg) + + // Find all members of this organization + const memberships = + await clerkClientInstance.organizations.getOrganizationMembershipList({ + organizationId: clerkOrgId, + }) + + // Sync each user and membership + for (const membership of memberships) { + const userId = membership.publicUserData.userId + + // Sync the user + const clerkUser = await clerkClientInstance.users.getUser(userId) + await syncUserToPocketBase(clerkUser) + + // Sync the membership + const membershipData = { + organization: { id: clerkOrgId }, + public_user_data: { user_id: userId }, + role: membership.role, + } + + await linkUserToOrganization(membershipData) + } + + return { + members: memberships.length, + organizationId: clerkOrgId, + status: 'success', + } + } catch (error) { + console.error(`Error reconciling organization ${clerkOrgId}:`, error) + throw error + } +} diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts new file mode 100644 index 0000000..f5c4de1 --- /dev/null +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -0,0 +1,129 @@ +import { secureCache } from '@/app/actions/services/clerk-sync/cacheService' +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, +} from '@/app/actions/services/clerk-sync/syncService' +import { auth, clerkClient } from '@clerk/nextjs/server' +// src/app/middlewares/syncMiddleware.ts +import { NextResponse } from 'next/server' + +/** + * Middleware function to ensure user and organization data is synced + * Acts as a fallback in case webhooks fail + * + * @param request The incoming request + * @returns The modified response + */ +export async function syncMiddleware(request: Request) { + // Only run this middleware for authenticated routes + const { orgId, userId } = auth() + + if (!userId) { + // User is not authenticated, skip this middleware + return NextResponse.next() + } + + try { + const cacheKey = `sync:${userId}:${orgId || 'none'}` + + // Check if we've recently synced this user to avoid excessive checks + // This is critical for performance in high-traffic scenarios + const cachedSync = secureCache.get(cacheKey, userId) + + if (!cachedSync) { + // If no cached result, perform the sync + await ensureUserAndOrgSync(userId, orgId) + + // Cache the result to avoid frequent syncs + // TTL of 5 minutes is a good balance between security and performance + secureCache.set( + cacheKey, + { synced: true, timestamp: Date.now() }, + userId, + 5 * 60 * 1000 + ) + } + } catch (error) { + // Log error but don't block the request + // This ensures the app remains functional even if sync fails + console.error('Sync middleware error:', error) + } + + // Continue with the request + return NextResponse.next() +} + +/** + * Ensures user and organization data is synchronized between Clerk and PocketBase + * + * @param clerkUserId The Clerk user ID + * @param clerkOrgId The Clerk organization ID (optional) + * @returns Object containing the synchronized user and organization + */ +export async function ensureUserAndOrgSync( + clerkUserId: string, + clerkOrgId?: string | null +) { + // 1. First, try to get fresh data from Clerk + const clerkClientInstance = await clerkClient() + const clerkUser = await clerkClientInstance.users.getUser(clerkUserId) + + // 2. Sync the user to PocketBase + await syncUserToPocketBase(clerkUser) + + // 3. If an organization ID is provided, sync that too + if (clerkOrgId) { + const clerkOrg = + await clerkClientInstance.organizations.getOrganization(clerkOrgId) + await syncOrganizationToPocketBase(clerkOrg) + + // 4. Ensure the user-organization relationship exists + // This uses data available in the membership endpoints + const membership = + await clerkClientInstance.organizations.getOrganizationMembership({ + organizationId: clerkOrgId, + userId: clerkUserId, + }) + + if (membership) { + // Prepare membership data in the format expected by linkUserToOrganization + const membershipData = { + organization: { id: clerkOrgId }, + public_user_data: { user_id: clerkUserId }, + role: membership.role, + } + + await linkUserToOrganization(membershipData) + } + } + + // Return the operation result + return { + status: 'success', + syncedAt: new Date().toISOString(), + } +} + +// For server action use, we create a higher-order function +/** + * Higher-order function that wraps server actions to ensure sync before execution + * + * @param handler The server action handler function + * @returns The wrapped handler with sync check + */ +export function withSync(handler: (data: any) => Promise) { + return async function syncProtectedAction(data: any): Promise { + 'use server' + + // Get auth context + const { orgId, userId } = await auth() + + if (userId) { + // Ensure data is synced before proceeding + await ensureUserAndOrgSync(userId, orgId) + } + + // Execute the original handler + return handler(data) + } +} diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts new file mode 100644 index 0000000..8ef2a47 --- /dev/null +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -0,0 +1,222 @@ +// src/app/actions/services/clerk-sync/syncService.ts +import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +import { User, Organization } from '@/types/types_pocketbase' +import { clerkClient } from '@clerk/nextjs' + +/** + * Synchronizes Clerk user data to PocketBase + * @param userData The user data from Clerk webhook or API + * @returns The created or updated user + */ +export async function syncUserToPocketBase(userData: any): Promise { + try { + const clerkId = userData.id + if (!clerkId) { + throw new Error('Missing Clerk ID in user data') + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Try to find the existing user first + let pbUser: User | null = null + try { + pbUser = await pb + .collection('users') + .getFirstListItem(`clerkId="${clerkId}"`) + } catch (error) { + // User doesn't exist yet, we'll create a new one + pbUser = null + } + + // Prepare the user data + const userDataToSync = { + avatar: userData.image_url || null, + canLogin: true, + clerkId: clerkId, + email: userData.email_addresses?.[0]?.email_address || '', + emailVisibility: true, + isAdmin: userData.public_metadata?.isAdmin || false, + lastLogin: new Date().toISOString(), + name: + `${userData.first_name || ''} ${userData.last_name || ''}`.trim() || + userData.username || + 'User', + phone: userData.phone_numbers?.[0]?.phone_number || null, + role: userData.public_metadata?.role || 'user', + verified: + userData.email_addresses?.[0]?.verification?.status === 'verified' || + false, + } + + // Update or create the user + if (pbUser) { + console.log(`Updating existing user ${clerkId} in PocketBase`) + return await pb.collection('users').update(pbUser.id, userDataToSync) + } else { + console.log(`Creating new user ${clerkId} in PocketBase`) + return await pb.collection('users').create(userDataToSync) + } + } catch (error) { + console.error('Error syncing user to PocketBase:', error) + throw error + } +} + +/** + * Synchronizes Clerk organization data to PocketBase + * @param orgData The organization data from Clerk webhook or API + * @returns The created or updated organization + */ +export async function syncOrganizationToPocketBase( + orgData: any +): Promise { + try { + const clerkId = orgData.id + if (!clerkId) { + throw new Error('Missing Clerk ID in organization data') + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Try to find the existing organization first + let pbOrg: Organization | null = null + try { + pbOrg = await pb + .collection('organizations') + .getFirstListItem(`clerkId="${clerkId}"`) + } catch (error) { + // Organization doesn't exist yet, we'll create a new one + pbOrg = null + } + + // Prepare the organization data + const orgDataToSync = { + address: orgData.public_metadata?.address || null, + clerkId: clerkId, + email: orgData.email_address || null, + name: orgData.name || 'Organization', + phone: orgData.phone_number || null, + settings: orgData.public_metadata?.settings || {}, + } + + // Update or create the organization + if (pbOrg) { + console.log(`Updating existing organization ${clerkId} in PocketBase`) + return await pb + .collection('organizations') + .update(pbOrg.id, orgDataToSync) + } else { + console.log(`Creating new organization ${clerkId} in PocketBase`) + return await pb.collection('organizations').create(orgDataToSync) + } + } catch (error) { + console.error('Error syncing organization to PocketBase:', error) + throw error + } +} + +/** + * Links a user to an organization in PocketBase based on Clerk membership data + * @param membershipData The membership data from Clerk webhook + * @returns Success status + */ +export async function linkUserToOrganization( + membershipData: any +): Promise { + try { + const userId = membershipData.public_user_data?.user_id + const orgId = membershipData.organization.id + const role = membershipData.role // e.g., 'admin', 'member' + + if (!userId || !orgId) { + throw new Error('Missing required IDs in membership data') + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Find the user in PocketBase by Clerk ID + const pbUser = await pb + .collection('users') + .getFirstListItem(`clerkId="${userId}"`) + + // Find the organization in PocketBase by Clerk ID + const pbOrg = await pb + .collection('organizations') + .getFirstListItem(`clerkId="${orgId}"`) + + // Check if the relation already exists + const existingRelations = await pb + .collection('user_organizations') + .getList(1, 1, { + filter: `user=`${pbUser.id}` && organization=`${pbOrg.id}``, + }) + + // If the relation doesn't exist, create it + if (existingRelations.totalItems === 0) { + await pb.collection('user_organizations').create({ + organization: pbOrg.id, + role: role || 'member', + user: pbUser.id, + }) + } else { + // Update the existing relation if needed + await pb + .collection('user_organizations') + .update(existingRelations.items[0].id, { + role: role || 'member', + }) + } + + // Update user if they're an admin in the organization + if (role === 'admin') { + await pb.collection('users').update(pbUser.id, { + isAdmin: true, + role: 'admin', + }) + } + + return true + } catch (error) { + console.error('Error linking user to organization:', error) + throw error + } +} + +/** + * Fetch user data from Clerk by ID + * @param clerkId The Clerk user ID + * @returns The user data from Clerk + */ +export async function getClerkUserById(clerkId: string): Promise { + try { + const clerkClientInstance = await clerkClient() + return await clerkClientInstance.users.getUser(clerkId) + } catch (error) { + console.error('Error fetching user from Clerk:', error) + throw error + } +} + +/** + * Fetch organization data from Clerk by ID + * @param clerkId The Clerk organization ID + * @returns The organization data from Clerk + */ +export async function getClerkOrganizationById(clerkId: string): Promise { + try { + const clerkClientInstance = await clerkClient() + return await clerkClientInstance.organizations.getOrganization(clerkId) + } catch (error) { + console.error('Error fetching organization from Clerk:', error) + throw error + } +} diff --git a/src/app/api/webhook/clerk/admin/reconcile/route.ts b/src/app/api/webhook/clerk/admin/reconcile/route.ts new file mode 100644 index 0000000..4449cba --- /dev/null +++ b/src/app/api/webhook/clerk/admin/reconcile/route.ts @@ -0,0 +1,84 @@ +import { + runFullReconciliation, + reconcileSpecificUser, + reconcileSpecificOrganization, +} from '@/app/actions/services/clerk-sync/reconciliation' +import { auth } from '@clerk/nextjs/server' +import { headers } from 'next/headers' +// src/app/api/admin/reconcile/route.ts +import { NextRequest, NextResponse } from 'next/server' + +/** + * Admin endpoint to trigger data reconciliation + * This endpoint is protected and requires either: + * 1. Admin authentication + * 2. A valid API key for automated tasks + */ +export async function POST(req: NextRequest) { + // Security checks - either admin authentication or API key + const isAuthenticated = await checkAuthentication(req) + + if (!isAuthenticated) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + try { + const body = await req.json() + const { organizationId, type, userId } = body + + // Run the appropriate reconciliation based on request type + if (type === 'full') { + const result = await runFullReconciliation() + return NextResponse.json(result) + } else if (type === 'user' && userId) { + const result = await reconcileSpecificUser(userId) + return NextResponse.json(result) + } else if (type === 'organization' && organizationId) { + const result = await reconcileSpecificOrganization(organizationId) + return NextResponse.json(result) + } else { + return NextResponse.json( + { + error: 'Invalid reconciliation type or missing parameters', + status: 'error', + }, + { status: 400 } + ) + } + } catch (error) { + console.error('Reconciliation API error:', error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Unknown error', + status: 'error', + }, + { status: 500 } + ) + } +} + +/** + * Checks if the request is authenticated + * Accepts either an admin user or a valid API key + * + * @param req The incoming request + * @returns Whether the request is authenticated + */ +async function checkAuthentication(req: NextRequest): Promise { + // Option 1: Check admin user + const { orgRole, userId } = await auth() + + if (userId && orgRole === 'admin') { + return true + } + + // Option 2: Check API key for automated tasks + const headerPayload = headers() + const apiKey = headerPayload.get('x-api-key') + + if (apiKey && apiKey === process.env.INTERNAL_API_KEY) { + return true + } + + return false +} diff --git a/src/app/api/webhook/clerk/route.ts b/src/app/api/webhook/clerk/route.ts new file mode 100644 index 0000000..6fd0b95 --- /dev/null +++ b/src/app/api/webhook/clerk/route.ts @@ -0,0 +1,121 @@ +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganization, +} from '@/app/actions/services/clerk-sync/syncService' +import { WebhookEvent } from '@clerk/nextjs/server' +import { headers } from 'next/headers' +// src/app/api/webhook/clerk/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { Webhook } from 'svix' + +/** + * Webhook handler for Clerk events. + * This endpoint receives and processes events from Clerk to keep PocketBase data in sync. + * + * @param req The incoming request containing the webhook payload + * @returns Response indicating success or error + */ +export async function POST(req: NextRequest) { + // Verify the webhook signature to ensure it's from Clerk + const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET + + if (!WEBHOOK_SECRET) { + console.error('Missing CLERK_WEBHOOK_SECRET') + return new NextResponse('Webhook secret not configured', { status: 500 }) + } + + // Get the signature and timestamp from the Svix headers + const headerPayload = headers() + const svixId = headerPayload.get('svix-id') + const svixTimestamp = headerPayload.get('svix-timestamp') + const svixSignature = headerPayload.get('svix-signature') + + // If there are missing Svix headers, reject the request + if (!svixId || !svixTimestamp || !svixSignature) { + return new NextResponse('Missing Svix headers', { status: 400 }) + } + + try { + // Get the raw request body + const payload = await req.text() + + // Create a new Svix instance with our webhook secret + const webhook = new Webhook(WEBHOOK_SECRET) + + // Verify the signature + const evt = webhook.verify(payload, { + 'svix-id': svixId, + 'svix-signature': svixSignature, + 'svix-timestamp': svixTimestamp, + }) as WebhookEvent + + // Get the ID of the webhook for idempotency + const eventId = evt.data.id || svixId + + // Check if this event was already processed (implement this function) + if (await hasProcessedEvent(eventId)) { + return NextResponse.json( + { message: 'Event already processed' }, + { status: 200 } + ) + } + + // Handle different event types + const eventType = evt.type + + if (eventType === 'user.created') { + await syncUserToPocketBase(evt.data) + } else if (eventType === 'user.updated') { + await syncUserToPocketBase(evt.data) + } else if (eventType === 'organization.created') { + await syncOrganizationToPocketBase(evt.data) + } else if (eventType === 'organization.updated') { + await syncOrganizationToPocketBase(evt.data) + } else if (eventType === 'organizationMembership.created') { + await linkUserToOrganization(evt.data) + } + // Add additional event handlers as needed + + // Mark the event as processed + await markEventAsProcessed(eventId) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error processing webhook:', error) + return new NextResponse('Webhook verification failed', { status: 400 }) + } +} + +/** + * Check if an event has already been processed to ensure idempotency + * @param eventId The unique ID of the event + * @returns Boolean indicating if the event was already processed + */ +async function hasProcessedEvent(eventId: string): Promise { + try { + // Implementation could use PocketBase to store processed events + // For now, we'll return false to process all events + // TODO: Implement proper event tracking + + return false + } catch (error) { + console.error('Error checking processed event:', error) + return false + } +} + +/** + * Mark an event as processed to avoid duplicate processing + * @param eventId The unique ID of the event to mark as processed + */ +async function markEventAsProcessed(eventId: string): Promise { + try { + // Implementation would store the event ID with a timestamp + // TODO: Implement proper event tracking + + console.log(`Event ${eventId} processed successfully`) + } catch (error) { + console.error('Error marking event as processed:', error) + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 5cf5770..bc46a2c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,13 +1,15 @@ +// src/middleware.ts +import { ensureUserAndOrgSync } from '@/app/actions/services/clerk-sync/syncMiddleware' import { clerkClient, clerkMiddleware, createRouteMatcher, } from '@clerk/nextjs/server' + const isProtectedRoute = createRouteMatcher(['/app(.*)']) const isPublicRoute = createRouteMatcher([ '/', - '/app(.*)', '/pricing(.*)', '/legals(.*)', '/marketing-components(.*)', @@ -22,7 +24,15 @@ const isPublicRoute = createRouteMatcher([ const isAdminRoute = createRouteMatcher(['/admin(.*)']) +// Exclude webhook routes from sync middleware to prevent circular dependencies +const isWebhookRoute = createRouteMatcher(['/api/webhook/(.*)']) + export default clerkMiddleware(async (auth, req) => { + // For webhook routes, bypass sync to prevent loops + if (isWebhookRoute(req)) { + return + } + if (isPublicRoute(req)) { return } @@ -43,6 +53,18 @@ export default clerkMiddleware(async (auth, req) => { return Response.redirect(new URL('/onboarding', req.url)) } + // Synchronize user and organization data + try { + // Only perform sync for protected routes and non-webhook routes + if (isProtectedRoute(req) && !isWebhookRoute(req)) { + await ensureUserAndOrgSync(authAwaited.userId, authAwaited.orgId) + } + } catch (error) { + console.error('Sync error in middleware:', error) + // Continue with the request even if sync fails + // This prevents the application from being unusable if sync fails + } + const clerkClientInstance = await clerkClient() const userMetadata = await clerkClientInstance.users.getUser( authAwaited.userId From d533f3e98c13df4a99c6e6e095cadaa801c5d5ce Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:29:29 +0200 Subject: [PATCH 14/73] chore(package): update svix version to 1.62.0 - Change svix dependency from caret to exact version - Ensure consistent behavior across environments --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 009ec41..4ee4fee 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "svix": "^1.62.0", + "svix": "1.62.0", "tailwind-merge": "3.0.2", "tw-animate-css": "1.2.5", "zod": "3.24.2", From df72072f652d5da7ab285447bb98b5c90ae27eda Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:33:47 +0200 Subject: [PATCH 15/73] feat(core): enhance cache security and metadata types - Introduce CacheableValue type for better data handling - Update SecureCache to use CacheableValue instead of any - Improve hash creation method with specific type usage - Refactor onboarding helper functions to utilize ClerkMetadata type - Modify organization retrieval to use object parameter for clarity --- .../services/clerk-sync/cacheService.ts | 22 +++++++++++----- .../services/clerk-sync/onBoardingHelper.ts | 26 +++++++++++-------- .../services/clerk-sync/reconciliation.ts | 3 +-- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts index c2761a7..b7cade0 100644 --- a/src/app/actions/services/clerk-sync/cacheService.ts +++ b/src/app/actions/services/clerk-sync/cacheService.ts @@ -1,6 +1,10 @@ -// src/app/actions/services/clerk-sync/cacheService.ts import crypto from 'crypto' +/** + * Type for cacheable data to avoid using any + */ +type CacheableValue = unknown + /** * Interface for cache entries with security features */ @@ -16,8 +20,8 @@ interface SecureCacheEntry { * Implements security best practices to prevent cache manipulation attacks */ class SecureCache { - private cache: Map> - private secretKey: string + private readonly cache: Map> + private readonly secretKey: string constructor() { this.cache = new Map() @@ -25,7 +29,7 @@ class SecureCache { // Use the environment secret or generate a random one per instance // This makes cache manipulation attacks significantly harder this.secretKey = - process.env.CACHE_SECRET || crypto.randomBytes(32).toString('hex') + process.env.CACHE_SECRET ?? crypto.randomBytes(32).toString('hex') } /** @@ -35,7 +39,7 @@ class SecureCache { * @param userId The user ID to include in the hash * @returns The generated hash */ - private createHash(data: any, userId: string): string { + private createHash(data: CacheableValue, userId: string): string { const content = JSON.stringify(data) + userId + this.secretKey return crypto.createHash('sha256').update(content).digest('hex') } @@ -50,7 +54,7 @@ class SecureCache { */ set( key: string, - value: any, + value: CacheableValue, userId: string, ttl: number = 2 * 60 * 1000 ): void { @@ -80,7 +84,11 @@ class SecureCache { * @param maxAge Optional maximum age in milliseconds * @returns The cached value or null if not found/invalid */ - get(key: string, userId: string, maxAge: number = 2 * 60 * 1000): any | null { + get( + key: string, + userId: string, + maxAge: number = 2 * 60 * 1000 + ): CacheableValue | null { const entry = this.cache.get(key) // No entry found diff --git a/src/app/actions/services/clerk-sync/onBoardingHelper.ts b/src/app/actions/services/clerk-sync/onBoardingHelper.ts index 01bf276..412bb0a 100644 --- a/src/app/actions/services/clerk-sync/onBoardingHelper.ts +++ b/src/app/actions/services/clerk-sync/onBoardingHelper.ts @@ -1,12 +1,14 @@ -import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' -// src/app/actions/services/clerk-sync/onboardingHelpers.ts -import { auth, clerkClient } from '@clerk/nextjs/server' - import { syncUserToPocketBase, syncOrganizationToPocketBase, linkUserToOrganization, -} from './syncService' +} from '@/app/actions/services/clerk-sync/syncService' +import { auth, clerkClient } from '@clerk/nextjs/server' + +/** + * Type for metadata to replace any + */ +type ClerkMetadata = Record /** * Imports an organization after it's created during onboarding @@ -27,8 +29,9 @@ export async function importOrganizationAfterCreation(clerkOrgId: string) { // Get the organization data from Clerk const clerkClientInstance = await clerkClient() - const clerkOrg = - await clerkClientInstance.organizations.getOrganization(clerkOrgId) + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: clerkOrgId, + }) // Sync the organization to PocketBase const organization = await syncOrganizationToPocketBase(clerkOrg) @@ -67,7 +70,7 @@ export async function importOrganizationAfterCreation(clerkOrgId: string) { * @param metadata The metadata to set for the user * @returns Success status */ -export async function updateUserMetadataAndSync(metadata: Record) { +export async function updateUserMetadataAndSync(metadata: ClerkMetadata) { 'use server' try { @@ -105,7 +108,7 @@ export async function updateUserMetadataAndSync(metadata: Record) { */ export async function updateOrgMetadataAndSync( orgId: string, - metadata: Record + metadata: ClerkMetadata ) { 'use server' @@ -123,8 +126,9 @@ export async function updateOrgMetadataAndSync( }) // Get the updated organization - const clerkOrg = - await clerkClientInstance.organizations.getOrganization(orgId) + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: orgId, + }) // Sync the updated organization to PocketBase await syncOrganizationToPocketBase(clerkOrg) diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts index 3d68924..61ccefe 100644 --- a/src/app/actions/services/clerk-sync/reconciliation.ts +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -1,6 +1,5 @@ // src/app/actions/services/clerk-sync/reconciliation.ts -import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' -import { clerkClient } from '@clerk/nextjs' +import { clerkClient } from '@clerk/nextjs/server' import { syncUserToPocketBase, From 94dbc4726ffe1280d5ca2ef7a22b84a0ef5b6b45 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:36:08 +0200 Subject: [PATCH 16/73] feat(core): enhance Clerk-PocketBase reconciliation - Import types for Clerk entities to improve type safety - Replace console.log with console.info for better logging clarity - Extract data arrays from paginated responses for users and organizations - Add error handling for missing user IDs in organization memberships - Improve overall structure and readability of the reconciliation process --- .../services/clerk-sync/reconciliation.ts | 87 ++++++++++++------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts index 61ccefe..48a29de 100644 --- a/src/app/actions/services/clerk-sync/reconciliation.ts +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -1,3 +1,6 @@ +// Import types for Clerk entities +import type { Organization, User } from '@clerk/nextjs/server' + // src/app/actions/services/clerk-sync/reconciliation.ts import { clerkClient } from '@clerk/nextjs/server' @@ -13,7 +16,7 @@ import { * Could be triggered by a cron job or scheduled task */ export async function runFullReconciliation() { - console.log( + console.info( 'Starting full Clerk-PocketBase reconciliation:', new Date().toISOString() ) @@ -23,68 +26,79 @@ export async function runFullReconciliation() { // Get all users and organizations from Clerk const clerkClientInstance = await clerkClient() - const clerkUsers = await clerkClientInstance.users.getUserList() - const clerkOrganizations = + const clerkUsersResponse = await clerkClientInstance.users.getUserList() + const clerkOrganizationsResponse = await clerkClientInstance.organizations.getOrganizationList() - console.log( + // Extract the data arrays from the paginated responses + const clerkUsers = clerkUsersResponse.data + const clerkOrganizations = clerkOrganizationsResponse.data + + console.info( `Found ${clerkUsers.length} users and ${clerkOrganizations.length} organizations in Clerk` ) // Sync all organizations first - console.log('Syncing organizations...') + console.info('Syncing organizations...') const orgResults = await Promise.allSettled( - clerkOrganizations.map(org => syncOrganizationToPocketBase(org)) + clerkOrganizations.map((org: Organization) => + syncOrganizationToPocketBase(org) + ) ) const successfulOrgs = orgResults.filter( - result => result.status === 'fulfilled' + (result: PromiseSettledResult) => result.status === 'fulfilled' ).length - console.log( + console.info( `Successfully synced ${successfulOrgs}/${clerkOrganizations.length} organizations` ) // Sync all users - console.log('Syncing users...') + console.info('Syncing users...') const userResults = await Promise.allSettled( - clerkUsers.map(user => syncUserToPocketBase(user)) + clerkUsers.map((user: User) => syncUserToPocketBase(user)) ) const successfulUsers = userResults.filter( - result => result.status === 'fulfilled' + (result: PromiseSettledResult) => result.status === 'fulfilled' ).length - console.log( + console.info( `Successfully synced ${successfulUsers}/${clerkUsers.length} users` ) // Sync organization memberships - console.log('Syncing organization memberships...') + console.info('Syncing organization memberships...') let membershipCount = 0 for (const org of clerkOrganizations) { try { - const memberships = + const membershipsResponse = await clerkClientInstance.organizations.getOrganizationMembershipList( { organizationId: org.id, } ) + // Get the actual membership data array + const memberships = membershipsResponse.data + for (const membership of memberships) { try { const membershipData = { organization: { id: org.id }, - public_user_data: { user_id: membership.publicUserData.userId }, + public_user_data: { user_id: membership.publicUserData?.userId }, role: membership.role, } await linkUserToOrganization(membershipData) membershipCount++ } catch (error) { - console.error( - `Error syncing membership for user ${membership.publicUserData.userId} in org ${org.id}:`, - error - ) + if (membership.publicUserData) { + console.error( + `Error syncing membership for user ${membership.publicUserData.userId} in org ${org.id}:`, + error + ) + } } } } catch (error) { @@ -95,13 +109,13 @@ export async function runFullReconciliation() { } } - console.log( + console.info( `Successfully synced ${membershipCount} organization memberships` ) // Done const totalTime = (Date.now() - startTime) / 1000 - console.log(`Reconciliation completed in ${totalTime.toFixed(2)} seconds`) + console.info(`Reconciliation completed in ${totalTime.toFixed(2)} seconds`) return { memberships: membershipCount, @@ -131,7 +145,7 @@ export async function runFullReconciliation() { */ export async function reconcileSpecificUser(clerkUserId: string) { try { - console.log(`Starting reconciliation for user ${clerkUserId}`) + console.info(`Starting reconciliation for user ${clerkUserId}`) // Get user data from Clerk const clerkClientInstance = await clerkClient() @@ -141,18 +155,22 @@ export async function reconcileSpecificUser(clerkUserId: string) { await syncUserToPocketBase(clerkUser) // Find all organizations this user belongs to - const memberships = + const membershipsResponse = await clerkClientInstance.users.getOrganizationMembershipList({ userId: clerkUserId, }) + // Extract the data array + const memberships = membershipsResponse.data + // Sync each organization and membership for (const membership of memberships) { const orgId = membership.organization.id // Sync the organization - const clerkOrg = - await clerkClientInstance.organizations.getOrganization(orgId) + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: orgId, + }) await syncOrganizationToPocketBase(clerkOrg) // Sync the membership @@ -184,25 +202,34 @@ export async function reconcileSpecificUser(clerkUserId: string) { */ export async function reconcileSpecificOrganization(clerkOrgId: string) { try { - console.log(`Starting reconciliation for organization ${clerkOrgId}`) + console.info(`Starting reconciliation for organization ${clerkOrgId}`) // Get organization data from Clerk const clerkClientInstance = await clerkClient() - const clerkOrg = - await clerkClientInstance.organizations.getOrganization(clerkOrgId) + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: clerkOrgId, + }) // Sync organization to PocketBase await syncOrganizationToPocketBase(clerkOrg) // Find all members of this organization - const memberships = + const membershipsResponse = await clerkClientInstance.organizations.getOrganizationMembershipList({ organizationId: clerkOrgId, }) + // Extract the data array + const memberships = membershipsResponse.data + // Sync each user and membership for (const membership of memberships) { - const userId = membership.publicUserData.userId + const userId = membership.publicUserData?.userId + + if (!userId) { + console.error(`User ID not found for membership ${membership.id}`) + continue + } // Sync the user const clerkUser = await clerkClientInstance.users.getUser(userId) From 74bf2950759ed3682b71874b6f9937a5f1ef34b4 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:37:54 +0200 Subject: [PATCH 17/73] feat(core): enhance sync middleware functionality - Add support for linking users to organizations - Update authentication handling to await user data - Refactor organization retrieval to use new API method - Improve membership data fetching with better structure --- .../services/clerk-sync/syncMiddleware.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts index f5c4de1..6ac43dd 100644 --- a/src/app/actions/services/clerk-sync/syncMiddleware.ts +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -2,11 +2,16 @@ import { secureCache } from '@/app/actions/services/clerk-sync/cacheService' import { syncUserToPocketBase, syncOrganizationToPocketBase, + linkUserToOrganization, } from '@/app/actions/services/clerk-sync/syncService' import { auth, clerkClient } from '@clerk/nextjs/server' -// src/app/middlewares/syncMiddleware.ts import { NextResponse } from 'next/server' +/** + * Type for any data accepted by server actions + */ +type ActionData = Record + /** * Middleware function to ensure user and organization data is synced * Acts as a fallback in case webhooks fail @@ -16,7 +21,7 @@ import { NextResponse } from 'next/server' */ export async function syncMiddleware(request: Request) { // Only run this middleware for authenticated routes - const { orgId, userId } = auth() + const { orgId, userId } = await auth() if (!userId) { // User is not authenticated, skip this middleware @@ -73,18 +78,22 @@ export async function ensureUserAndOrgSync( // 3. If an organization ID is provided, sync that too if (clerkOrgId) { - const clerkOrg = - await clerkClientInstance.organizations.getOrganization(clerkOrgId) + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: clerkOrgId, + }) await syncOrganizationToPocketBase(clerkOrg) // 4. Ensure the user-organization relationship exists - // This uses data available in the membership endpoints - const membership = - await clerkClientInstance.organizations.getOrganizationMembership({ + // This uses data available in the membership EndpointSecretOut + const memberships = + await clerkClientInstance.organizations.getOrganizationMembershipList({ organizationId: clerkOrgId, - userId: clerkUserId, }) + const membership = memberships.data.find( + m => m.publicUserData?.userId === clerkUserId + ) + if (membership) { // Prepare membership data in the format expected by linkUserToOrganization const membershipData = { @@ -111,8 +120,8 @@ export async function ensureUserAndOrgSync( * @param handler The server action handler function * @returns The wrapped handler with sync check */ -export function withSync(handler: (data: any) => Promise) { - return async function syncProtectedAction(data: any): Promise { +export function withSync(handler: (data: ActionData) => Promise) { + return async function syncProtectedAction(data: ActionData): Promise { 'use server' // Get auth context From e2d7c861c8e2c4452924eefed8c3aef1196f4b78 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 15:46:56 +0200 Subject: [PATCH 18/73] feat(sync): enhance data synchronization with Clerk - Add type definitions for Clerk user, organization, and membership data - Update sync functions to use specific types for better clarity - Improve error handling with console.error messages - Modify middleware to include a TODO for webhook request checks - Adjust header retrieval in API routes to be asynchronous --- .../services/clerk-sync/reconciliation.ts | 1 + .../services/clerk-sync/syncMiddleware.ts | 7 +- .../services/clerk-sync/syncService.ts | 114 +++++++++++++++--- .../webhook/clerk/admin/reconcile/route.ts | 2 +- src/app/api/webhook/clerk/route.ts | 2 +- 5 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts index 48a29de..688f1d3 100644 --- a/src/app/actions/services/clerk-sync/reconciliation.ts +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -1,3 +1,4 @@ +// this file is used to sync the data between Clerk and PocketBase // Import types for Clerk entities import type { Organization, User } from '@clerk/nextjs/server' diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts index 6ac43dd..edcda66 100644 --- a/src/app/actions/services/clerk-sync/syncMiddleware.ts +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -16,10 +16,13 @@ type ActionData = Record * Middleware function to ensure user and organization data is synced * Acts as a fallback in case webhooks fail * - * @param request The incoming request * @returns The modified response */ -export async function syncMiddleware(request: Request) { +export async function syncMiddleware() { + // we will probably need to add : + // todo: add a check to see if the request is for the webhook + // * @param request The incoming request -> to be able to check if the request is for the webhook + // Only run this middleware for authenticated routes const { orgId, userId } = await auth() diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index 8ef2a47..ff7d860 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -1,14 +1,72 @@ // src/app/actions/services/clerk-sync/syncService.ts import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' import { User, Organization } from '@/types/types_pocketbase' -import { clerkClient } from '@clerk/nextjs' +import { clerkClient } from '@clerk/nextjs/server' + +/** + * Type definitions for Clerk user data + */ +type ClerkUserData = { + id: string + first_name?: string + last_name?: string + username?: string + image_url?: string + email_addresses?: Array<{ + email_address: string + verification?: { + status?: string + } + }> + phone_numbers?: Array<{ + phone_number: string + }> + public_metadata?: { + isAdmin?: boolean + role?: string + [key: string]: unknown + } + [key: string]: unknown +} + +/** + * Type definitions for Clerk organization data + */ +type ClerkOrganizationData = { + id: string + name?: string + email_address?: string + phone_number?: string + public_metadata?: { + address?: string + settings?: Record + [key: string]: unknown + } + [key: string]: unknown +} + +/** + * Type definitions for Clerk membership data + */ +type ClerkMembershipData = { + organization: { + id: string + } + public_user_data?: { + user_id: string + } + role?: string + [key: string]: unknown +} /** * Synchronizes Clerk user data to PocketBase * @param userData The user data from Clerk webhook or API * @returns The created or updated user */ -export async function syncUserToPocketBase(userData: any): Promise { +export async function syncUserToPocketBase( + userData: ClerkUserData +): Promise { try { const clerkId = userData.id if (!clerkId) { @@ -29,6 +87,7 @@ export async function syncUserToPocketBase(userData: any): Promise { } catch (error) { // User doesn't exist yet, we'll create a new one pbUser = null + console.error('Error syncing user to PocketBase:', error) } // Prepare the user data @@ -53,10 +112,10 @@ export async function syncUserToPocketBase(userData: any): Promise { // Update or create the user if (pbUser) { - console.log(`Updating existing user ${clerkId} in PocketBase`) + console.info(`Updating existing user ${clerkId} in PocketBase`) return await pb.collection('users').update(pbUser.id, userDataToSync) } else { - console.log(`Creating new user ${clerkId} in PocketBase`) + console.info(`Creating new user ${clerkId} in PocketBase`) return await pb.collection('users').create(userDataToSync) } } catch (error) { @@ -71,7 +130,7 @@ export async function syncUserToPocketBase(userData: any): Promise { * @returns The created or updated organization */ export async function syncOrganizationToPocketBase( - orgData: any + orgData: ClerkOrganizationData ): Promise { try { const clerkId = orgData.id @@ -93,6 +152,7 @@ export async function syncOrganizationToPocketBase( } catch (error) { // Organization doesn't exist yet, we'll create a new one pbOrg = null + console.error('Error syncing organization to PocketBase:', error) } // Prepare the organization data @@ -107,12 +167,12 @@ export async function syncOrganizationToPocketBase( // Update or create the organization if (pbOrg) { - console.log(`Updating existing organization ${clerkId} in PocketBase`) + console.info(`Updating existing organization ${clerkId} in PocketBase`) return await pb .collection('organizations') .update(pbOrg.id, orgDataToSync) } else { - console.log(`Creating new organization ${clerkId} in PocketBase`) + console.info(`Creating new organization ${clerkId} in PocketBase`) return await pb.collection('organizations').create(orgDataToSync) } } catch (error) { @@ -127,7 +187,7 @@ export async function syncOrganizationToPocketBase( * @returns Success status */ export async function linkUserToOrganization( - membershipData: any + membershipData: ClerkMembershipData ): Promise { try { const userId = membershipData.public_user_data?.user_id @@ -146,18 +206,18 @@ export async function linkUserToOrganization( // Find the user in PocketBase by Clerk ID const pbUser = await pb .collection('users') - .getFirstListItem(`clerkId="${userId}"`) + .getFirstListItem(`clerkId=${userId}`) // Find the organization in PocketBase by Clerk ID const pbOrg = await pb .collection('organizations') - .getFirstListItem(`clerkId="${orgId}"`) + .getFirstListItem(`clerkId=${orgId}`) // Check if the relation already exists const existingRelations = await pb .collection('user_organizations') .getList(1, 1, { - filter: `user=`${pbUser.id}` && organization=`${pbOrg.id}``, + filter: `user="${pbUser.id}" && organization="${pbOrg.id}"`, }) // If the relation doesn't exist, create it @@ -196,10 +256,23 @@ export async function linkUserToOrganization( * @param clerkId The Clerk user ID * @returns The user data from Clerk */ -export async function getClerkUserById(clerkId: string): Promise { +export async function getClerkUserById( + clerkId: string +): Promise { try { const clerkClientInstance = await clerkClient() - return await clerkClientInstance.users.getUser(clerkId) + const user = await clerkClientInstance.users.getUser(clerkId) + return { + // email_addresses: user.emailAddresses.map(email => ({ + // email_address: email.emailAddress, + // verification: email.verification, + // })), + // first_name: user.firstName, + id: user.id, + image_url: user.imageUrl, + // last_name: user.lastName, + // username: user.username, + } } catch (error) { console.error('Error fetching user from Clerk:', error) throw error @@ -211,10 +284,21 @@ export async function getClerkUserById(clerkId: string): Promise { * @param clerkId The Clerk organization ID * @returns The organization data from Clerk */ -export async function getClerkOrganizationById(clerkId: string): Promise { +export async function getClerkOrganizationById( + clerkId: string +): Promise { try { const clerkClientInstance = await clerkClient() - return await clerkClientInstance.organizations.getOrganization(clerkId) + const organization = + await clerkClientInstance.organizations.getOrganization({ + organizationId: clerkId, + }) + return { + // email_address: organization.email_address, + id: organization.id, + name: organization.name, + // phone_number: organization.phone_number, + } } catch (error) { console.error('Error fetching organization from Clerk:', error) throw error diff --git a/src/app/api/webhook/clerk/admin/reconcile/route.ts b/src/app/api/webhook/clerk/admin/reconcile/route.ts index 4449cba..b3d1faa 100644 --- a/src/app/api/webhook/clerk/admin/reconcile/route.ts +++ b/src/app/api/webhook/clerk/admin/reconcile/route.ts @@ -73,7 +73,7 @@ async function checkAuthentication(req: NextRequest): Promise { } // Option 2: Check API key for automated tasks - const headerPayload = headers() + const headerPayload = await headers() const apiKey = headerPayload.get('x-api-key') if (apiKey && apiKey === process.env.INTERNAL_API_KEY) { diff --git a/src/app/api/webhook/clerk/route.ts b/src/app/api/webhook/clerk/route.ts index 6fd0b95..de26be3 100644 --- a/src/app/api/webhook/clerk/route.ts +++ b/src/app/api/webhook/clerk/route.ts @@ -26,7 +26,7 @@ export async function POST(req: NextRequest) { } // Get the signature and timestamp from the Svix headers - const headerPayload = headers() + const headerPayload = await headers() const svixId = headerPayload.get('svix-id') const svixTimestamp = headerPayload.get('svix-timestamp') const svixSignature = headerPayload.get('svix-signature') From 9c0a0c82f838a4e9f65ce09f894504f7fc990baf Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 16:30:47 +0200 Subject: [PATCH 19/73] feat(docs): update README and add dev instructions - Add project running instructions to README - Format API link for clarity - Include installation and development commands - Introduce logging helper for better debugging in webhook handling --- README.md | 10 +++- bun.lock | 5 +- package.json | 1 + .../services/clerk-sync/cacheService.ts | 49 +++++++++++++++++-- .../webhook/clerk/{ => organization}/route.ts | 6 +++ src/lib/testingHelpers.ts | 18 +++++++ 6 files changed, 84 insertions(+), 5 deletions(-) rename src/app/api/webhook/clerk/{ => organization}/route.ts (93%) create mode 100644 src/lib/testingHelpers.ts diff --git a/README.md b/README.md index 2b9a44e..3abc662 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,13 @@ -- api : -- https://api.fortooling.forhives.fr/_/ +- - See our vaultwarden for password and credentials + +## Dev + +## how to run the project + +`bun install` +`bun run dev` +`ngrok http 3000` diff --git a/bun.lock b/bun.lock index 78f6331..fee0e92 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "crypto": "^1.0.1", "dayjs": "1.11.13", "framer-motion": "12.6.2", "heroicons": "2.2.0", @@ -29,7 +30,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "svix": "^1.62.0", + "svix": "1.62.0", "tailwind-merge": "3.0.2", "tw-animate-css": "1.2.5", "zod": "3.24.2", @@ -407,6 +408,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto": ["crypto@1.0.1", "", {}, "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], diff --git a/package.json b/package.json index 4ee4fee..a1f49ba 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "crypto": "1.0.1", "dayjs": "1.11.13", "framer-motion": "12.6.2", "heroicons": "2.2.0", diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts index b7cade0..4679fb9 100644 --- a/src/app/actions/services/clerk-sync/cacheService.ts +++ b/src/app/actions/services/clerk-sync/cacheService.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto' +import { createHash as nodeCreateHash } from 'crypto' /** * Type for cacheable data to avoid using any @@ -29,11 +29,17 @@ class SecureCache { // Use the environment secret or generate a random one per instance // This makes cache manipulation attacks significantly harder this.secretKey = - process.env.CACHE_SECRET ?? crypto.randomBytes(32).toString('hex') + process.env.CACHE_SECRET || + Array.from({ length: 32 }, () => + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0') + ).join('') } /** * Creates a cryptographic hash to verify data integrity + * Compatible with Edge runtime * * @param data The data to hash * @param userId The user ID to include in the hash @@ -41,7 +47,44 @@ class SecureCache { */ private createHash(data: CacheableValue, userId: string): string { const content = JSON.stringify(data) + userId + this.secretKey - return crypto.createHash('sha256').update(content).digest('hex') + + // Use Web Crypto API which is available in Edge runtime + if (typeof crypto !== 'undefined' && crypto.subtle) { + // Convert string to ArrayBuffer + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(content) + + // Create a promise that will be resolved synchronously + let hashHex = '' + + // Use subtle.digest synchronously (in a hacky way for this context) + const p = crypto.subtle.digest('SHA-256', dataBuffer).then(hashBuffer => { + // Convert buffer to byte array + const hashArray = Array.from(new Uint8Array(hashBuffer)) + // Convert bytes to hex string + hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + }) + + if (hashHex) return hashHex + return this.simpleHash(content) + } + + return this.simpleHash(content) + } + + /** + * Simple hash function as fallback + * Not as secure as crypto but works in all environments + */ + private simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + // Convert to hex string and ensure it's 64 chars long for consistency + return Math.abs(hash).toString(16).padStart(64, '0') } /** diff --git a/src/app/api/webhook/clerk/route.ts b/src/app/api/webhook/clerk/organization/route.ts similarity index 93% rename from src/app/api/webhook/clerk/route.ts rename to src/app/api/webhook/clerk/organization/route.ts index de26be3..2fe50a8 100644 --- a/src/app/api/webhook/clerk/route.ts +++ b/src/app/api/webhook/clerk/organization/route.ts @@ -3,6 +3,7 @@ import { syncOrganizationToPocketBase, linkUserToOrganization, } from '@/app/actions/services/clerk-sync/syncService' +import { logData } from '@/lib/testingHelpers' import { WebhookEvent } from '@clerk/nextjs/server' import { headers } from 'next/headers' // src/app/api/webhook/clerk/route.ts @@ -17,6 +18,8 @@ import { Webhook } from 'svix' * @returns Response indicating success or error */ export async function POST(req: NextRequest) { + logData('🚀 Webhook received', { timestamp: new Date().toISOString() }, true) + // Verify the webhook signature to ensure it's from Clerk const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET @@ -39,6 +42,7 @@ export async function POST(req: NextRequest) { try { // Get the raw request body const payload = await req.text() + logData('🚀 Webhook payload', payload, true) // Create a new Svix instance with our webhook secret const webhook = new Webhook(WEBHOOK_SECRET) @@ -49,6 +53,7 @@ export async function POST(req: NextRequest) { 'svix-signature': svixSignature, 'svix-timestamp': svixTimestamp, }) as WebhookEvent + logData('🚀 Webhook event', evt, true) // Get the ID of the webhook for idempotency const eventId = evt.data.id || svixId @@ -65,6 +70,7 @@ export async function POST(req: NextRequest) { const eventType = evt.type if (eventType === 'user.created') { + logData('🚀 User created', evt.data, true) await syncUserToPocketBase(evt.data) } else if (eventType === 'user.updated') { await syncUserToPocketBase(evt.data) diff --git a/src/lib/testingHelpers.ts b/src/lib/testingHelpers.ts new file mode 100644 index 0000000..72cc66c --- /dev/null +++ b/src/lib/testingHelpers.ts @@ -0,0 +1,18 @@ +// src/app/lib/testingHelpers.ts +export function logData(label: string, data: unknown, important = false) { + const prefix = important ? '🔴 ' : '🔵 ' + console.info(`${prefix}${label}:`) + + try { + // Format the data nicely + if (typeof data === 'object') { + console.info(JSON.stringify(data, null, 2)) + } else { + console.info(data) + } + console.info('----------------------------') + } catch (error) { + console.info('Error logging data:', error) + console.info('Raw data:', data) + } +} From c5049153799e94e22a0b0f9fb857671b1d636ec8 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 16:45:44 +0200 Subject: [PATCH 20/73] docs(api): update README with webhook setup instructions - Add ngrok URL setup details for webhooks - Specify the need to change webhook secrets/endpoints locally - Provide example URLs for organization and user webhooks --- README.md | 9 +++++++++ .../api/webhook/clerk/organization-membership/route.ts | 0 2 files changed, 9 insertions(+) create mode 100644 src/app/api/webhook/clerk/organization-membership/route.ts diff --git a/README.md b/README.md index 3abc662..1be1175 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,12 @@ api : `bun install` `bun run dev` `ngrok http 3000` + +We get the ngrok url , and we setup this one in the webhook list from clerk +-> we need to change every webhook secret / endpoint for every webhook locally needed + +for example, we can have : + +- +- +- diff --git a/src/app/api/webhook/clerk/organization-membership/route.ts b/src/app/api/webhook/clerk/organization-membership/route.ts new file mode 100644 index 0000000..e69de29 From ecb02e1bcebb166302f00965fe81a08a11348556 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 17:21:59 +0200 Subject: [PATCH 21/73] feat(api): add Clerk webhook handling for users and organizations - Implement webhook verification for organization and user events - Add handlers for organization creation, update, and deletion - Create handlers for user creation, update, and deletion - Update documentation to include new webhook functionality - Introduce utility function for signature verification --- .cursor/rules/rules-stack-technique.mdc | 2 + .cursor/rules/rules-technique-prompt.mdc | 44 +---- env.example | 6 +- .../clerk/organization-membership/route.ts | 88 +++++++++ .../api/webhook/clerk/organization/route.ts | 167 +++++++----------- src/app/api/webhook/clerk/user/route.ts | 79 +++++++++ src/lib/webhookUtils.ts | 49 +++++ 7 files changed, 292 insertions(+), 143 deletions(-) create mode 100644 src/app/api/webhook/clerk/user/route.ts create mode 100644 src/lib/webhookUtils.ts diff --git a/.cursor/rules/rules-stack-technique.mdc b/.cursor/rules/rules-stack-technique.mdc index ba2083e..455a0e9 100644 --- a/.cursor/rules/rules-stack-technique.mdc +++ b/.cursor/rules/rules-stack-technique.mdc @@ -51,6 +51,8 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les - Isolation multi-tenant intégrée - **Zod** - Validation de schémas pour les données d'entrée - **Tan stack form** - Gestion de formulaires avec validation côté client +- **Webhook** - En local on va utiliser ngrok, en prod on va utiliser les endpoints classiques + ### Backend diff --git a/.cursor/rules/rules-technique-prompt.mdc b/.cursor/rules/rules-technique-prompt.mdc index 74f0931..30adfa5 100644 --- a/.cursor/rules/rules-technique-prompt.mdc +++ b/.cursor/rules/rules-technique-prompt.mdc @@ -11,49 +11,14 @@ Tu es un assistant de développement expert spécialisé dans la création d'une ## 📋 Directives Générales -- **Langue**: Toujours coder et commenter en anglais +- **Langue**: Toujours coder et commenter en anglais, très important ! - **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques - **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown - **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code - **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée - **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité -## 🏗️ Stack Technique à Respecter - -### Frontend - -- **Framework**: Next.js 15+, React 19+ -- **Styling**: Tailwind CSS 4+, shadcn/ui -- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 -- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) -- **Forms**: Tan stack form + Zod pour la validation -- **Animations**: Framer Motion, Rive pour les animations complexes -- **UI**: Composants shadcn/ui, icônes Lucide React -- **Mobile**: next-pwa, WebNFC API, QR code fallback - -### Backend - -- **API**: Next.js Server Actions avec middleware de protection centralisé -- **Validation**: Zod pour la validation des données -- **Backend Service**: PocketBase -- **Authentification**: Clerk 6+ -- **Paiements**: Stripe -- **Recherche**: Algolia -- **Stockage**: Cloudflare R2 -- **Emails**: Resend -- **SMS**: Twilio -- **Temps réel**: Socket.io -- **Tâches asynchrones**: Temporal.io - -### DevOps & Sécurité - -- **Déploiement**: Coolify, Docker -- **CI/CD**: GitHub Actions -- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip -- **Analytics**: Umami -- **Sécurité API**: Rate limiting, CORS, Helmet - -## 11. Schéma / visualisation +## Schéma / visualisation Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](mdc:../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/mermaid-js.github.io) et suivre les bonnes pratiques de ce langage. @@ -74,7 +39,7 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - **React**: Composants fonctionnels avec hooks - **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) - **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types -- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement +- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement, et éviter absolument le props drilling. ### Documentation @@ -117,6 +82,7 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - Éviter d'exposer des données sensibles dans le frontend - Ne pas dupliquer la logique d'authentification et de validation - Éviter de créer des Server Actions sans utiliser le middleware de protection +- Tout les imports doivent utiliser le format '@app/.....' et pas de chemin relatif ou absolu direct ## 🔄 Processus de Travail @@ -126,4 +92,4 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m 4. Suggère des améliorations ou alternatives si pertinent 5. Offre des conseils pour les tests et la maintenance -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. \ No newline at end of file +Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. diff --git a/env.example b/env.example index c9ffcb4..daed409 100644 --- a/env.example +++ b/env.example @@ -5,4 +5,8 @@ NEXT_PUBLIC_CLERK_SIGN_UP_URL=--/sign-up NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=--/app PB_TOKEN_API_ADMIN=--- -PB_API_URL=--- \ No newline at end of file +PB_API_URL=--- + +CLERK_WEBHOOK_SECRET_ORGANIZATION=-- +CLERK_WEBHOOK_SECRET_USER=-- +CLERK_WEBHOOK_SECRET_ORGANIZATION_MEMBERSHIP=-- \ No newline at end of file diff --git a/src/app/api/webhook/clerk/organization-membership/route.ts b/src/app/api/webhook/clerk/organization-membership/route.ts index e69de29..d51d53e 100644 --- a/src/app/api/webhook/clerk/organization-membership/route.ts +++ b/src/app/api/webhook/clerk/organization-membership/route.ts @@ -0,0 +1,88 @@ +import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Handles webhook events from Clerk related to organization memberships + */ +export async function POST(req: NextRequest) { + // Verify webhook signature + const isValid = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION + ) + + if (!isValid) { + console.error('Invalid webhook signature for organization membership event') + return new NextResponse('Invalid signature', { status: 401 }) + } + + try { + // Get the request body + const body = await req.json() + const { data, type } = body + + console.log(`Processing organization membership webhook: ${type}`) + + // Handle different organization membership events + switch (type) { + case 'organizationMembership.created': + await handleMembershipCreated(data) + break + case 'organizationMembership.updated': + await handleMembershipUpdated(data) + break + case 'organizationMembership.deleted': + await handleMembershipDeleted(data) + break + default: + console.log(`Unhandled organization membership event type: ${type}`) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error processing organization membership webhook:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * Handles organization membership creation event + */ +async function handleMembershipCreated(data: any) { + const { id: membershipId, organization, public_user_data, role } = data + + console.log( + `Membership created: User ${public_user_data.user_id} joined organization ${organization.id} as ${role}` + ) + + // TODO: Store the organization membership in your database +} + +/** + * Handles organization membership update event + */ +async function handleMembershipUpdated(data: any) { + const { id: membershipId, organization, public_user_data, role } = data + + console.log( + `Membership updated: User ${public_user_data.user_id} in organization ${organization.id}, role: ${role}` + ) + + // TODO: Update the organization membership in your database +} + +/** + * Handles organization membership deletion event + */ +async function handleMembershipDeleted(data: any) { + const { id: membershipId, organization, public_user_data } = data + + console.log( + `Membership deleted: User ${public_user_data.user_id} removed from organization ${organization.id}` + ) + + // TODO: Remove or mark as deleted the organization membership in your database +} diff --git a/src/app/api/webhook/clerk/organization/route.ts b/src/app/api/webhook/clerk/organization/route.ts index 2fe50a8..3e1d04c 100644 --- a/src/app/api/webhook/clerk/organization/route.ts +++ b/src/app/api/webhook/clerk/organization/route.ts @@ -1,127 +1,88 @@ -import { - syncUserToPocketBase, - syncOrganizationToPocketBase, - linkUserToOrganization, -} from '@/app/actions/services/clerk-sync/syncService' -import { logData } from '@/lib/testingHelpers' -import { WebhookEvent } from '@clerk/nextjs/server' -import { headers } from 'next/headers' -// src/app/api/webhook/clerk/route.ts +import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { log } from 'console' import { NextRequest, NextResponse } from 'next/server' -import { Webhook } from 'svix' /** - * Webhook handler for Clerk events. - * This endpoint receives and processes events from Clerk to keep PocketBase data in sync. - * - * @param req The incoming request containing the webhook payload - * @returns Response indicating success or error + * Handles webhook events from Clerk related to organizations */ export async function POST(req: NextRequest) { - logData('🚀 Webhook received', { timestamp: new Date().toISOString() }, true) - - // Verify the webhook signature to ensure it's from Clerk - const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET - - if (!WEBHOOK_SECRET) { - console.error('Missing CLERK_WEBHOOK_SECRET') - return new NextResponse('Webhook secret not configured', { status: 500 }) - } - - // Get the signature and timestamp from the Svix headers - const headerPayload = await headers() - const svixId = headerPayload.get('svix-id') - const svixTimestamp = headerPayload.get('svix-timestamp') - const svixSignature = headerPayload.get('svix-signature') - - // If there are missing Svix headers, reject the request - if (!svixId || !svixTimestamp || !svixSignature) { - return new NextResponse('Missing Svix headers', { status: 400 }) + // Verify webhook signature + const isValid = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION + ) + + if (!isValid) { + console.error('Invalid webhook signature for organization event') + return new NextResponse('Invalid signature', { status: 401 }) } try { - // Get the raw request body - const payload = await req.text() - logData('🚀 Webhook payload', payload, true) - - // Create a new Svix instance with our webhook secret - const webhook = new Webhook(WEBHOOK_SECRET) - - // Verify the signature - const evt = webhook.verify(payload, { - 'svix-id': svixId, - 'svix-signature': svixSignature, - 'svix-timestamp': svixTimestamp, - }) as WebhookEvent - logData('🚀 Webhook event', evt, true) - - // Get the ID of the webhook for idempotency - const eventId = evt.data.id || svixId - - // Check if this event was already processed (implement this function) - if (await hasProcessedEvent(eventId)) { - return NextResponse.json( - { message: 'Event already processed' }, - { status: 200 } - ) - } - - // Handle different event types - const eventType = evt.type - - if (eventType === 'user.created') { - logData('🚀 User created', evt.data, true) - await syncUserToPocketBase(evt.data) - } else if (eventType === 'user.updated') { - await syncUserToPocketBase(evt.data) - } else if (eventType === 'organization.created') { - await syncOrganizationToPocketBase(evt.data) - } else if (eventType === 'organization.updated') { - await syncOrganizationToPocketBase(evt.data) - } else if (eventType === 'organizationMembership.created') { - await linkUserToOrganization(evt.data) + // Get the request body + const body = await req.json() + const { data, type } = body + + console.log(`Processing organization webhook: ${type}`) + + // Handle different organization events + switch (type) { + case 'organization.created': + await handleOrganizationCreated(data) + break + case 'organization.updated': + await handleOrganizationUpdated(data) + break + case 'organization.deleted': + await handleOrganizationDeleted(data) + break + default: + console.log(`Unhandled organization event type: ${type}`) } - // Add additional event handlers as needed - - // Mark the event as processed - await markEventAsProcessed(eventId) return NextResponse.json({ success: true }) } catch (error) { - console.error('Error processing webhook:', error) - return new NextResponse('Webhook verification failed', { status: 400 }) + console.error('Error processing organization webhook:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) } } /** - * Check if an event has already been processed to ensure idempotency - * @param eventId The unique ID of the event - * @returns Boolean indicating if the event was already processed + * Handles organization creation event + * @param data - Organization data from Clerk webhook */ -async function hasProcessedEvent(eventId: string): Promise { - try { - // Implementation could use PocketBase to store processed events - // For now, we'll return false to process all events - // TODO: Implement proper event tracking +async function handleOrganizationCreated(data: any) { + const { id: clerkId, name } = data + console.log(data) - return false - } catch (error) { - console.error('Error checking processed event:', error) - return false - } + // TODO: Implement organization creation in your database + // Example: Create organization in PocketBase with the Clerk ID } /** - * Mark an event as processed to avoid duplicate processing - * @param eventId The unique ID of the event to mark as processed + * Handles organization update event + * @param data - Organization data from Clerk webhook */ -async function markEventAsProcessed(eventId: string): Promise { - try { - // Implementation would store the event ID with a timestamp - // TODO: Implement proper event tracking +async function handleOrganizationUpdated(data: any) { + const { id: clerkId, image_url, logo_url, name, public_metadata, slug } = data - console.log(`Event ${eventId} processed successfully`) - } catch (error) { - console.error('Error marking event as processed:', error) - } + console.log(`Organization updated: ${clerkId} (${name})`) + + // TODO: Implement organization update in your database + // Example: Update organization details in PocketBase +} + +/** + * Handles organization deletion event + * @param data - Organization data from Clerk webhook + */ +async function handleOrganizationDeleted(data: any) { + const { id: clerkId } = data + + console.log(`Organization deleted: ${clerkId}`) + + // TODO: Implement organization deletion in your database + // Example: Mark organization as deleted in PocketBase } diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts new file mode 100644 index 0000000..c188d30 --- /dev/null +++ b/src/app/api/webhook/clerk/user/route.ts @@ -0,0 +1,79 @@ +import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Handles webhook events from Clerk related to users + */ +export async function POST(req: NextRequest) { + // Verify webhook signature + const isValid = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_USER + ) + + if (!isValid) { + console.error('Invalid webhook signature for user event') + return new NextResponse('Invalid signature', { status: 401 }) + } + + try { + // Get the request body + const body = await req.json() + const { data, type } = body + + console.log(`Processing user webhook: ${type}`) + + // Handle different user events + switch (type) { + case 'user.created': + await handleUserCreated(data) + break + case 'user.updated': + await handleUserUpdated(data) + break + case 'user.deleted': + await handleUserDeleted(data) + break + default: + console.log(`Unhandled user event type: ${type}`) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error processing user webhook:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * Handles user creation event + */ +async function handleUserCreated(data: any) { + const { id: clerkId } = data + console.log(`User created: ${clerkId}`) + + // TODO: Create user in your database +} + +/** + * Handles user update event + */ +async function handleUserUpdated(data: any) { + const { id: clerkId } = data + console.log(`User updated: ${clerkId}`) + + // TODO: Update user in your database +} + +/** + * Handles user deletion event + */ +async function handleUserDeleted(data: any) { + const { id: clerkId } = data + console.log(`User deleted: ${clerkId}`) + + // TODO: Handle user deletion in your database +} diff --git a/src/lib/webhookUtils.ts b/src/lib/webhookUtils.ts new file mode 100644 index 0000000..f981c1e --- /dev/null +++ b/src/lib/webhookUtils.ts @@ -0,0 +1,49 @@ +import * as crypto from 'crypto' +import { NextRequest } from 'next/server' + +/** + * Validates a webhook signature from Clerk - simplified version + */ +export async function verifyClerkWebhook( + req: NextRequest, + secret: string | undefined +): Promise { + if (!secret) { + console.error('Missing Clerk webhook secret') + return false + } + + // Get the signature headers + const svix_id = req.headers.get('svix-id') + const svix_timestamp = req.headers.get('svix-timestamp') + const svix_signature = req.headers.get('svix-signature') + + if (!svix_id || !svix_timestamp || !svix_signature) { + console.error('Missing Svix headers') + return false + } + + // Get the raw body + const payload = await req.text() + const signaturePayload = `${svix_id}.${svix_timestamp}.${payload}` + + // Verify signatures + const expectedSignatures = svix_signature.split(' ') + const signatures = expectedSignatures.map(sig => { + const [version, signature] = sig.split(',') + return { signature, version } + }) + + // Check if any signature matches + return signatures.some(({ signature }) => { + try { + const hmac = crypto.createHmac('sha256', secret) + hmac.update(signaturePayload) + const digest = hmac.digest('hex') + return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature)) + } catch (error) { + console.error('Error verifying signature:', error) + return false + } + }) +} From 49b26aaa9a16d682a0bdfbc0baf909a3361cf7a1 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 17:41:19 +0200 Subject: [PATCH 22/73] feat(api): add webhook handling for organizations and users - Implement central handler for processing Clerk webhook events - Add methods to handle organization creation, update, and deletion - Include membership management through webhooks - Create base methods for record operations with permission handling - Introduce error logging for better debugging during webhook processing --- .cursor/md/example-webhook-clerk.md | 325 ++++++++++++++++++ .cursor/md/rules-cahier-des-charges.md | 212 ------------ .cursor/md/rules-diagram-mermaid.md | 98 ------ .cursor/md/rules-stack-technique.md | 197 ----------- .cursor/md/rules-technique-prompt.md | 129 ------- .cursor/rules/rules-technique-prompt.mdc | 3 +- .../services/clerk-sync/webhook-handler.ts | 66 ++++ .../services/pocketbase/baseService.ts | 127 +++++++ .../pocketbase/organizationService.ts | 299 ++++++++++++++++ .../api/webhook/clerk/organization/route.ts | 86 ++++- src/app/api/webhook/clerk/route.ts | 54 +++ src/app/api/webhook/clerk/user/route.ts | 96 +++++- src/types/webhooks.ts | 64 ++++ 13 files changed, 1097 insertions(+), 659 deletions(-) create mode 100644 .cursor/md/example-webhook-clerk.md delete mode 100644 .cursor/md/rules-cahier-des-charges.md delete mode 100644 .cursor/md/rules-diagram-mermaid.md delete mode 100644 .cursor/md/rules-stack-technique.md delete mode 100644 .cursor/md/rules-technique-prompt.md create mode 100644 src/app/actions/services/clerk-sync/webhook-handler.ts create mode 100644 src/app/api/webhook/clerk/route.ts create mode 100644 src/types/webhooks.ts diff --git a/.cursor/md/example-webhook-clerk.md b/.cursor/md/example-webhook-clerk.md new file mode 100644 index 0000000..f9618be --- /dev/null +++ b/.cursor/md/example-webhook-clerk.md @@ -0,0 +1,325 @@ +# Organization + +```json +{ + "data": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": "https://example.org/example.png", + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013202977 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013202977, + "type": "organization.created" +} +``` + +``` +{ + "data": { + "deleted": true, + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "object": "organization" + }, + "event_attributes": { + "http_request": { + "client_ip": "", + "user_agent": "" + } + }, + "object": "event", + "timestamp": 1661861640000, + "type": "organization.deleted" +} +``` + +``` +{ + "data": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": "https://example.com/example.png", + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013466465 + }, + "event_attributes": { + "http_request": { + "client_ip": "", + "user_agent": "" + } + }, + "object": "event", + "timestamp": 1654013466465, + "type": "organization.updated" +} +``` + +# Organization Membership + +``` +{ + "data": { + "created_at": 1654013203217, + "id": "orgmem_29w9IptNja3mP8GDXpquBwN2qR9", + "object": "organization_membership", + "organization": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": "https://example.com/example.png", + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013202977 + }, + "public_user_data": { + "first_name": "Example", + "identifier": "example@example.org", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": "Example", + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "user_id": "user_29w83sxmDNGwOuEthce5gg56FcC" + }, + "role": "admin", + "updated_at": 1654013203217 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013203217, + "type": "organizationMembership.created" +} +``` + +``` +{ + "data": { + "created_at": 1654013847054, + "id": "orgmem_29wAbjiJs6aZuPq7AzmkW9dwmyl", + "object": "organization_membership", + "organization": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": null, + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013567994 + }, + "public_user_data": { + "first_name": null, + "identifier": "example@example.org", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": null, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "user_id": "user_29wACSk1DjeUCwsS6SbbgIgilMy" + }, + "role": "basic_member", + "updated_at": 1654013847054 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013847054, + "type": "organizationMembership.deleted" +} +``` + +``` +{ + "data": { + "created_at": 1654013847054, + "id": "orgmem_29wAbjiJs6aZuPq7AzmkW9dwmyl", + "object": "organization_membership", + "organization": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": null, + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013567994 + }, + "public_user_data": { + "first_name": null, + "identifier": "example@example.org", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": null, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "user_id": "user_29wACSk1DjeUCwsS6SbbgIgilMy" + }, + "role": "basic_member", + "updated_at": 1654013910646 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013910646, + "type": "organizationMembership.updated" +} +``` + +# User + +``` +{ + "data": { + "birthday": "", + "created_at": 1654012591514, + "email_addresses": [ + { + "email_address": "example@example.org", + "id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "linked_to": [], + "object": "email_address", + "verification": { + "status": "verified", + "strategy": "ticket" + } + } + ], + "external_accounts": [], + "external_id": "567772", + "first_name": "Example", + "gender": "", + "id": "user_29w83sxmDNGwOuEthce5gg56FcC", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": "Example", + "last_sign_in_at": 1654012591514, + "object": "user", + "password_enabled": true, + "phone_numbers": [], + "primary_email_address_id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "primary_phone_number_id": null, + "primary_web3_wallet_id": null, + "private_metadata": {}, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "public_metadata": {}, + "two_factor_enabled": false, + "unsafe_metadata": {}, + "updated_at": 1654012591835, + "username": null, + "web3_wallets": [] + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654012591835, + "type": "user.created" +} +``` + +``` +{ + "data": { + "deleted": true, + "id": "user_29wBMCtzATuFJut8jO2VNTVekS4", + "object": "user" + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1661861640000, + "type": "user.deleted" +} +``` + +``` +{ + "data": { + "birthday": "", + "created_at": 1654012591514, + "email_addresses": [ + { + "email_address": "example@example.org", + "id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "linked_to": [], + "object": "email_address", + "reserved": true, + "verification": { + "attempts": null, + "expire_at": null, + "status": "verified", + "strategy": "admin" + } + } + ], + "external_accounts": [], + "external_id": null, + "first_name": "Example", + "gender": "", + "id": "user_29w83sxmDNGwOuEthce5gg56FcC", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": null, + "last_sign_in_at": null, + "object": "user", + "password_enabled": true, + "phone_numbers": [], + "primary_email_address_id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "primary_phone_number_id": null, + "primary_web3_wallet_id": null, + "private_metadata": {}, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "public_metadata": {}, + "two_factor_enabled": false, + "unsafe_metadata": {}, + "updated_at": 1654012824306, + "username": null, + "web3_wallets": [] + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654012824306, + "type": "user.updated" +} +``` diff --git a/.cursor/md/rules-cahier-des-charges.md b/.cursor/md/rules-cahier-des-charges.md deleted file mode 100644 index d8e6fa9..0000000 --- a/.cursor/md/rules-cahier-des-charges.md +++ /dev/null @@ -1,212 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -## 1. Contexte et problématique générale - -### 1.1 Problématique adressée - -De nombreuses entreprises possèdent et gèrent un parc d'équipements qu'elles doivent suivre, attribuer et entretenir. Ces équipements peuvent représenter plusieurs dizaines à centaines d'articles différents (outils, matériel technique, appareils spécialisés, etc.). - -Les systèmes traditionnels de gestion présentent des lacunes importantes : - -- Suivi manuel chronophage et source d'erreurs -- Difficulté à localiser rapidement les équipements -- Absence d'historique fiable des mouvements et utilisations -- Complexité pour gérer les attributions -- Manque de visibilité globale sur l'état du parc -- Coût très elevé pour des balises gps précies (e.g hilti etc) -- Impossibilité d'appliquer ça sur des éléments autres - -## 2. Objectifs de la plateforme SaaS - -Développer une plateforme SaaS de gestion d'équipements qui permettra de : - -- Centraliser l'inventaire complet du parc matériel -- Suivre la localisation de chaque équipement en temps réel grâce à des étiquettes nfc/qr -- Gérer l'attribution des équipements aux utilisateurs et aux projets/emplacements -- Automatiser la détection des entrées/sorties d'équipements via des points de scan -- Conserver l'historique de tous les mouvements et utilisations -- Fournir des analyses et statistiques d'utilisation avancées -- Offrir une solution adaptable à différents secteurs d'activité - -## 3. Besoins fonctionnels détaillés - -### 3.1 Gestion multi-organisations - -- Support de plusieurs organisations clientes avec isolation complète des données -- Paramétrage par organisation (terminologie, champs personnalisés, flux de travail) -- Gestion des rôles et permissions par organisation - -### 3.2 Gestion des équipements - -- Inventaire complet avec informations détaillées : - - Référence unique et code NFC/qr associé - - Nom et description - - Date d'acquisition et valeur - - État et niveau d'usure - - Spécifications techniques (type, marque, modèle, etc.) - - Catégorie de rattachement - - Champs personnalisables selon le secteur d'activité -- Création, modification et suppression d'équipements -- Association d'un équipement à une catégorie spécifique -- Support pour documentation technique, photos et fichiers associés -- Gestion des maintenances préventives et curatives - -### 3.3 Suivi automatisé par NFC // ou SCAN QR Code - -- Intégration avec des étiquettes nfc/qr à faible coût / ou équivalent -- Points de scan aux entrées/sorties des zones de stockage -- Scan mobile via smartphones/tablettes pour vérification terrain -- Détection automatique des mouvements d'équipements -- Alertes en cas de sortie non autorisée - - mail - - sms - - alerte perso -- Cartographie des dernières localisations connues - -### 3.4 Gestion des affectations - -- Attribution d'équipements à : - - Un utilisateur/employé - - Un projet/chantier - - Un emplacement physique -- Enregistrement des dates de début et fin d'affectation -- Affectation groupée de plusieurs équipements simultanément -- Workflows d'approbation configurables -- Historique complet des affectations - -### 3.5 Gestion des utilisateurs - -- Enregistrement des informations sur les utilisateurs : - - Profil complet (nom, prénom, contact, etc.) - - Rôle et permissions dans le système - - Département/équipe de rattachement -- Suivi des équipements attribués à chaque utilisateur -- Gestion des accès par niveau de permission - -### 3.6 Gestion des projets/emplacements/chantiers - -- Structure flexible adaptable selon les besoins : - - Projets temporaires avec dates de début/fin - - Emplacements physiques permanents - - Zones géographiques -- Hiérarchisation possible (bâtiment > étage > pièce) -- Géolocalisation et cartographie -- Suivi des équipements affectés - -### 3.7 Catégorisation des équipements - -- Système de catégories et sous-catégories multiniveau -- Attributs spécifiques par catégorie d'équipement -- Système de préfixage automatique des références -- Organisation logique adaptée au secteur d'activité - -### 3.8 Analyses et statistiques avancées - -- Dashboard personnalisable avec indicateurs clés -- Rapports sur les taux d'utilisation des équipements -- Analyses prédictives pour planification des besoins -- Alertes sur équipements sous-utilisés ou sur-utilisés -- Statistiques par utilisateur, projet, catégorie et équipement -- Rapports exportables dans différents formats - -### 3.9 Intégration et API - -- API REST complète pour intégration avec d'autres systèmes -- Intégration possible avec des ERP, GMAO, ou logiciels comptables -- Export/import de données en différents formats -- Webhooks pour événements système - -## 4. Description fonctionnelle détaillée - -### 4.1 Structure générale - -- Interface responsive accessible sur tous supports -- Cinq modules principaux : Utilisateurs, Projets/Emplacements, Catégories, Équipements, Affectations -- Navigation intuitive avec accès contextuel aux fonctionnalités -- Dashboard personnalisable par type d'utilisateur - -### 4.2 Module de gestion des utilisateurs - -- Annuaire complet avec recherche avancée et filtres -- Gestion des profils avec historique d'activité -- Vue des équipements actuellement affectés -- Statistiques d'utilisation et de responsabilité matérielle -- Système de notification personnalisable - -### 4.3 Module de gestion des projets/emplacements - -- Structure adaptable selon le secteur d'activité -- Visualisation des équipements actuellement présents -- Timeline d'occupation des ressources -- Planification des besoins futurs -- Cartographie des emplacements physiques - -### 4.4 Module de gestion des catégories - -- Arborescence des catégories personnalisable -- Gestion des attributs spécifiques par catégorie -- Règles de nommage et d'attribution automatisées -- Templates pour accélérer la création d'équipements similaires -- Rapports analytiques par catégorie - -### 4.5 Module de gestion des équipements - -- Interface complète de gestion d'inventaire -- Fiche détaillée avec historique complet de chaque équipement -- Journal d'activité avec tous les mouvements et scans nfc/qr -- Suivi du cycle de vie (de l'acquisition à la mise au rebut) -- Planning de maintenance préventive -- Système d'alerte pour maintenance ou certification à renouveler - -### 4.6 Module de gestion des affectations - -- Processus guidé d'affectation avec validation -- Scan nfc/qr pour confirmation de prise en charge -- Vue calendaire des disponibilités -- Système de réservation anticipée -- Alertes de retour pour affectations arrivant à échéance -- Workflows configurables avec approbations multi-niveaux - -### 4.7 Fonctionnalités de recherche avancée - -- Recherche globale intelligente sur tous les critères -- Filtres contextuels et sauvegarde de recherches favorites -- Recherche par scan nfc/qr pour identification rapide -- Suggestions intelligentes basées sur l'historique - -### 4.8 Module d'administration et paramétrage - -- Configuration complète adaptée à chaque organisation -- Personnalisation de la terminologie et des champs -- Gestion des droits et rôles utilisateurs -- Audit logs pour toutes les actions système -- Paramétrage des notifications et alertes - -## 5. Interactions et automatisations - -### 5.1 Workflow de scan nfc/qr - -- Scan à l'entrée/sortie des zones de stockage -- Mise à jour automatique de la localisation -- Vérification de la légitimité du mouvement -- Création automatique d'affectation sur scan sortant -- Clôture automatique d'affectation sur scan entrant - -### 5.2 Interactions entre équipements - -- Gestion des relations parent/enfant entre équipements -- Suivi des assemblages/désassemblages -- Alertes sur incompatibilités potentielles -- Recommandations d'équipements complémentaires - -### 5.3 Automatisation des processus - -- Rappels automatiques pour retours d'équipements -- Alertes de maintenance basées sur l'utilisation réelle -- Détection d'anomalies dans les patterns d'utilisation -- Suggestions d'optimisation du parc - -Ce cahier des charges est destiné à servir de référence pour le développement d'une plateforme SaaS de gestion d'équipements adaptable à différents secteurs d'activité, avec un accent particulier sur l'automatisation via technologie nfc/qr et l'analyse avancée des données. diff --git a/.cursor/md/rules-diagram-mermaid.md b/.cursor/md/rules-diagram-mermaid.md deleted file mode 100644 index f5a2f75..0000000 --- a/.cursor/md/rules-diagram-mermaid.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} - -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} - -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} - -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} - -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} - -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} - -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees - -User }o--o{ Assignment : "is assigned to" - -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" - -Project }o--o{ Assignment : includes diff --git a/.cursor/md/rules-stack-technique.md b/.cursor/md/rules-stack-technique.md deleted file mode 100644 index ba2083e..0000000 --- a/.cursor/md/rules-stack-technique.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Stack Technique Finale - Plateforme SaaS de Gestion d'Équipements NFC/QR - -## 1. Vue d'ensemble - -Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les technologies modernes du web pour offrir une solution robuste, performante et évolutive. L'architecture est conçue pour être hautement optimisée, sécurisée et facile à maintenir. - -## 2. Frontend - -### Framework & UI - -- **Next.js 15+** - Framework React avec App Router et Server Components -- **React 19+** - Bibliothèque UI pour construire des interfaces interactives -- **Tailwind CSS 4+** - Framework CSS utility-first pour le styling -- **shadcn/ui** - Composants UI réutilisables basés sur Radix UI -- **Lucide React** - Bibliothèque d'icônes SVG -- **Framer Motion** - Animations et transitions fluides -- **Rive** - Animations complexes et interactives - -### Gestion d'état client - -- **Zustand** - Gestion d'état global légère et simple - - Utilisé pour éviter le prop drilling - - Stockage des préférences utilisateur, thèmes, filtres - - État partagé entre composants distants - - On utilisera pas les React Context, mais bien Zustand quand on aura besoin de ce genre de système - -### PWA & Mobile - -- **next-pwa** - Transforme l'application en Progressive Web App -- **WebNFC API** - Accès aux fonctionnalités NFC pour les appareils compatibles -- **QR Code fallback** - Solution alternative pour les appareils sans NFC - -### Qualité & Tests - -- **TypeScript** - Typage statique pour une meilleure qualité de code -- **ESLint/Prettier** - Linting et formatage de code -- **Vitest** - Tests unitaires rapides -- **Playwright** - Tests end-to-end - -## 3. Backend & API - -### API & Validation - -- **Next.js Server Actions** - Actions serveur typées et sécurisées - - Pattern de protection centralisé (HOF withProtection) - - Isolation multi-tenant intégrée -- **Zod** - Validation de schémas pour les données d'entrée -- **Tan stack form** - Gestion de formulaires avec validation côté client - -### Backend - -- **PocketBase** - Backend as a service - - Services modulaires (baseService, equipmentService, etc.) - - Gestion d'authentification et permissions - - Stockage de données structurées - -### Sécurité API - -- **Rate limiting** - Protection contre les abus -- **CORS** - Sécurité pour les requêtes cross-origin -- **Helmet** - Sécurisation des headers HTTP - -## 4. Services & Intégrations - -### Authentification & Paiements - -- **Clerk 6+** - Authentification complète et gestion des utilisateurs -- **Stripe** - Traitement des paiements et gestion des abonnements - -### Recherche & Stockage - -- **Algolia** - Recherche rapide et pertinente -- **Cloudflare R2** - Stockage d'objets compatible S3 - -### Communication & Notifications - -- **Resend** - Service d'emails transactionnels -- **Twilio** - SMS et notifications mobiles -- **Socket.io** - Communication temps réel pour le monitoring - -### Fonctionnalités spécifiques - -- **OpenStreetMap + Leaflet.js** - Cartographie et géolocalisation -- **React-PDF** - Génération de rapports PDF -- **SheetJS** - Export de données en format Excel -- **Temporal.io** - Orchestration de workflows et tâches asynchrones - -## 5. Infrastructure & DevOps - -### Déploiement & CI/CD - -- **Coolify** - Plateforme self-hosted pour le déploiement -- **Docker** - Conteneurisation des services -- **GitHub Actions** - Automatisation CI/CD - -### Monitoring & Observabilité - -- **Prometheus + Grafana** - Collecte et visualisation de métriques -- **Loki** - Agrégation et exploration de logs -- **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) -- **Umami** - Analytics respectueux de la vie privée - -## 6. Architecture multi-tenant - -- Architecture à schéma unique avec discrimination par tenant_id -- Isolation des données par organisation au niveau des Server Actions -- Middleware de protection centralisé pour les vérifications d'accès -- Optimisation des requêtes avec PocketBase - -## 7. Intégration NFC/QR - -- Approche hybride WebNFC + QR Code -- Points de scan fixes (entrées/sorties) -- Options pour scanners Bluetooth dans les zones de forte utilisation - -## 8. Optimisations & Performance - -- **SEO** - Optimisation pour la partie publique (landing) - - Screaming Frog pour l'audit - - Lighthouse pour les bonnes pratiques -- **Web Vitals** - Suivi continu des métriques de performance -- **Unlighthouse/IBM checker** - Outils d'analyse supplémentaires - -## 9. Documentation - -- **Swagger/OpenAPI** - Documentation d'API auto-générée -- **Docusaurus** - Documentation utilisateur et technique - -## 10. Structure du projet -``` -src/ -├── app/ # Next.js App Router -│ ├── (application)/ # Application sécurisée -│ │ ├── (clerk)/ # Routes authentifiées par Clerk -│ │ └── app/ # Fonctionnalités principales de l'application -│ ├── (marketing)/ # Routes publiques (landing) -│ └── actions/ # Server Actions sécurisées -│ ├── equipment/ # Actions pour la gestion des équipements -│ └── services/ # Services d'accès aux données -│ └── pocketbase/ # Services PocketBase modulaires -├── components/ # Composants React partagés -│ ├── app/ # Composants spécifiques à l'application -│ ├── magicui/ # Composants UI avancés (animations, effets) -│ └── ui/ # Composants UI de base (shadcn) -├── hooks/ # Hooks React personnalisés -├── lib/ # Code utilitaire partagé -├── stores/ # Stores Zustand -└── types/ # Types TypeScript partagés -``` - - -## 11. Schéma d'Architecture Globale - -```mermaid -flowchart TB - subgraph Client["Client (Browser/Mobile)"] - UI["Next.js UI"] - ZustandStore["Zustand Store"] - TanStackForm["Tan Stack Form"] - NFC["NFC/QR Scanner"] - end - - subgraph ServerSide["Server Side (Next.js)"] - ServerActions["Server Actions"] - Middleware["Protection Middleware"] - ClerkAuth["Clerk Auth"] - end - - subgraph Services["External Services"] - PocketBase["PocketBase"] - Stripe["Stripe Payments"] - CloudflareR2["Cloudflare R2"] - Algolia["Algolia Search"] - Resend["Resend Email"] - Twilio["Twilio SMS"] - end - - UI <--> ZustandStore - UI <--> TanStackForm - UI <--> NFC - TanStackForm --> ServerActions - UI <--> ServerActions - ServerActions <--> Middleware - Middleware <--> ClerkAuth - ServerActions <--> PocketBase - ServerActions <--> Stripe - ServerActions <--> CloudflareR2 - ServerActions <--> Algolia - ServerActions <--> Resend - ServerActions <--> Twilio -``` - diff --git a/.cursor/md/rules-technique-prompt.md b/.cursor/md/rules-technique-prompt.md deleted file mode 100644 index 74f0931..0000000 --- a/.cursor/md/rules-technique-prompt.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Prompt Système pour Assistant de Développement SaaS - Plateforme de Gestion d'Équipements NFC/QR - -## 🎯 Contexte du Projet - -Tu es un assistant de développement expert spécialisé dans la création d'une plateforme SaaS de gestion d'équipements avec tracking NFC/QR. Ce système permet aux entreprises de suivre, attribuer et maintenir leur parc d'équipements via une interface moderne et des fonctionnalités avancées de scanning et de reporting. - -## 📋 Directives Générales - -- **Langue**: Toujours coder et commenter en anglais -- **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques -- **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown -- **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code -- **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée -- **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité - -## 🏗️ Stack Technique à Respecter - -### Frontend - -- **Framework**: Next.js 15+, React 19+ -- **Styling**: Tailwind CSS 4+, shadcn/ui -- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 -- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) -- **Forms**: Tan stack form + Zod pour la validation -- **Animations**: Framer Motion, Rive pour les animations complexes -- **UI**: Composants shadcn/ui, icônes Lucide React -- **Mobile**: next-pwa, WebNFC API, QR code fallback - -### Backend - -- **API**: Next.js Server Actions avec middleware de protection centralisé -- **Validation**: Zod pour la validation des données -- **Backend Service**: PocketBase -- **Authentification**: Clerk 6+ -- **Paiements**: Stripe -- **Recherche**: Algolia -- **Stockage**: Cloudflare R2 -- **Emails**: Resend -- **SMS**: Twilio -- **Temps réel**: Socket.io -- **Tâches asynchrones**: Temporal.io - -### DevOps & Sécurité - -- **Déploiement**: Coolify, Docker -- **CI/CD**: GitHub Actions -- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip -- **Analytics**: Umami -- **Sécurité API**: Rate limiting, CORS, Helmet - -## 11. Schéma / visualisation - -Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](mdc:../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. -Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/mermaid-js.github.io) et suivre les bonnes pratiques de ce langage. - -## 🖋️ Conventions de Code & Documentation - -### Structuration du Code - -- Architecture modulaire et maintenable -- Séparation claire des préoccupations (SoC) -- DRY (Don't Repeat Yourself) et SOLID principles -- Pattern par fonctionnalité plutôt que par type technique -- Centralisation des vérifications de sécurité et d'autorisation - -### Style de Code - -- **TypeScript**: Types stricts et exhaustifs -- **React**: Composants fonctionnels avec hooks -- **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) -- **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types -- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement - -### Documentation - -- **JSDoc** pour toutes les fonctions, hooks, et types complexes: - -```typescript -/** - * Fetches equipment data based on provided filters - * @param {EquipmentFilters} filters - The filters to apply to the query - * @param {QueryOptions} options - Optional query parameters - * @returns {Promise} Array of equipment matching filters - * @throws {ApiError} When the API request fails - */ -``` - -- **Commentaires de code**: Explique le "pourquoi", pas le "quoi" -- Ajoute des logs explicatifs aux endroits clés - -### Tests - -- Tests unitaires avec Vitest -- Tests end-to-end avec Playwright -- Privilégier les tests pour la logique métier critique - -## 🤝 Collaboration Attendue - -- **Proactivité**: Anticipe les besoins et problèmes potentiels -- **Pédagogie**: Explique les concepts complexes et les choix d'architecture -- **Adaptabilité**: Ajuste-toi à mes besoins et préférences au fur et à mesure -- **Progressivité**: Commence par les fondamentaux puis avance vers des implémentations plus complexes -- **Optimisations**: Suggère des améliorations mais priorise la lisibilité et la maintenabilité - -## 🚨 Anti-patterns à Éviter - -- Ne pas utiliser de classes React (préférer les composants fonctionnels) -- Éviter les any/unknown en TypeScript si possible -- Ne pas réinventer ce qui existe déjà dans les bibliothèques choisies -- Éviter les dépendances inutiles ou redondantes -- Ne pas mélanger les styles (préférer Tailwind) -- Éviter d'exposer des données sensibles dans le frontend -- Ne pas dupliquer la logique d'authentification et de validation -- Éviter de créer des Server Actions sans utiliser le middleware de protection - -## 🔄 Processus de Travail - -1. Comprends d'abord mon besoin ou problème -2. Propose une approche structurée avec les technologies appropriées -3. Implémente en expliquant les choix techniques -4. Suggère des améliorations ou alternatives si pertinent -5. Offre des conseils pour les tests et la maintenance - -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. \ No newline at end of file diff --git a/.cursor/rules/rules-technique-prompt.mdc b/.cursor/rules/rules-technique-prompt.mdc index 30adfa5..4bede28 100644 --- a/.cursor/rules/rules-technique-prompt.mdc +++ b/.cursor/rules/rules-technique-prompt.mdc @@ -40,6 +40,7 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) - **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types - **État**: Préférer `useState`, `useReducer` localement, Zustand globalement, et éviter absolument le props drilling. +- **Types**: les types seront regroupés correctement au même endroit dans /types, pour éviter toute redondances et duplications des types et du code ### Documentation @@ -82,7 +83,7 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - Éviter d'exposer des données sensibles dans le frontend - Ne pas dupliquer la logique d'authentification et de validation - Éviter de créer des Server Actions sans utiliser le middleware de protection -- Tout les imports doivent utiliser le format '@app/.....' et pas de chemin relatif ou absolu direct +- Tout les imports doivent utiliser le format '@/...' et pas de chemin relatif ou absolu direct ## 🔄 Processus de Travail diff --git a/src/app/actions/services/clerk-sync/webhook-handler.ts b/src/app/actions/services/clerk-sync/webhook-handler.ts new file mode 100644 index 0000000..25d818b --- /dev/null +++ b/src/app/actions/services/clerk-sync/webhook-handler.ts @@ -0,0 +1,66 @@ +/** + * Central handler for processing Clerk webhook events + * Routes to appropriate service methods based on event type + */ +import { WebhookEvent } from "@clerk/nextjs/server"; +import { WebhookProcessingResult } from "@/types/webhooks"; +import * as organizationService from "../pocketbase/organizationService"; +import * as userService from "../pocketbase/userService"; + +/** + * Process a Clerk webhook event + * @param {WebhookEvent} event - The webhook event from Clerk + * @returns {Promise} Result of processing + */ +export async function processWebhookEvent(event: WebhookEvent): Promise { + console.log(`Processing webhook event: ${event.type}`); + + try { + // Temporarily elevate permissions for webhook processing + const elevated = true; + + // Handle organization events + if (event.type === "organization.created") { + return await organizationService.handleWebhookCreated(event.data, elevated); + } + else if (event.type === "organization.updated") { + return await organizationService.handleWebhookUpdated(event.data, elevated); + } + else if (event.type === "organization.deleted") { + return await organizationService.handleWebhookDeleted(event.data, elevated); + } + + // Handle membership events + else if (event.type === "organizationMembership.created") { + return await organizationService.handleMembershipWebhookCreated(event.data, elevated); + } + else if (event.type === "organizationMembership.updated") { + return await organizationService.handleMembershipWebhookUpdated(event.data, elevated); + } + else if (event.type === "organizationMembership.deleted") { + return await organizationService.handleMembershipWebhookDeleted(event.data, elevated); + } + + // Handle user events + else if (event.type === "user.created") { + return await userService.handleWebhookCreated(event.data, elevated); + } + else if (event.type === "user.updated") { + return await userService.handleWebhookUpdated(event.data, elevated); + } + else if (event.type === "user.deleted") { + return await userService.handleWebhookDeleted(event.data, elevated); + } + + // Unknown event type + else { + return { success: false, message: `Unhandled webhook event type: ${event.type}` }; + } + } catch (error) { + console.error(`Error processing webhook ${event.type}:`, error); + return { + success: false, + message: `Error processing webhook: ${error instanceof Error ? error.message : "Unknown error"}` + }; + } +} \ No newline at end of file diff --git a/src/app/actions/services/pocketbase/baseService.ts b/src/app/actions/services/pocketbase/baseService.ts index df0258c..a94a6b7 100644 --- a/src/app/actions/services/pocketbase/baseService.ts +++ b/src/app/actions/services/pocketbase/baseService.ts @@ -53,3 +53,130 @@ export const handlePocketBaseError = ( throw new Error(`Unknown PocketBase error${contextMsg}`) } + +/** + * Base method for creating records with permission handling + * @param collection - Collection name + * @param data - Record data + * @param elevated - Whether this operation has elevated permissions (e.g., from webhook) + */ +export async function createRecord( + collection: string, + data: any, + elevated = false +) { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to initialize PocketBase client') + } + + // Create the record with the PocketBase instance + return await pb.collection(collection).create(data) + } catch (error) { + console.error(`Error creating record in ${collection}:`, error) + throw error + } +} + +/** + * Base method for updating records with permission handling + * @param collection - Collection name + * @param id - Record ID + * @param data - Updated data + * @param elevated - Whether this operation has elevated permissions + */ +export async function updateRecord( + collection: string, + id: string, + data: any, + elevated = false +) { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to initialize PocketBase client') + } + + // Update the record with the PocketBase instance + return await pb.collection(collection).update(id, data) + } catch (error) { + console.error(`Error updating record in ${collection}:`, error) + throw error + } +} + +/** + * Base method for deleting records with permission handling + * @param collection - Collection name + * @param id - Record ID + * @param elevated - Whether this operation has elevated permissions + */ +export async function deleteRecord( + collection: string, + id: string, + elevated = false +) { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to initialize PocketBase client') + } + + // Delete the record with the PocketBase instance + return await pb.collection(collection).delete(id) + } catch (error) { + console.error(`Error deleting record in ${collection}:`, error) + throw error + } +} + +/** + * Base method for retrieving a single record by ID + * @param collection - Collection name + * @param id - Record ID + */ +export async function getRecordById(collection: string, id: string) { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to initialize PocketBase client') + } + + return await pb.collection(collection).getOne(id) + } catch (error) { + console.error(`Error getting record by ID in ${collection}:`, error) + throw error + } +} + +/** + * Base method for listing records with filters + * @param collection - Collection name + * @param page - Page number + * @param perPage - Items per page + * @param filter - Filter string + * @param sort - Sort string + */ +export async function listRecords( + collection: string, + page = 1, + perPage = 50, + filter = '', + sort = '' +) { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to initialize PocketBase client') + } + + return await pb.collection(collection).getList(page, perPage, { + filter, + sort, + }) + } catch (error) { + console.error(`Error listing records in ${collection}:`, error) + throw error + } +} diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts index 336892b..7ffa666 100644 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ b/src/app/actions/services/pocketbase/organizationService.ts @@ -11,6 +11,13 @@ import { SecurityError, } from '@/app/actions/services/pocketbase/securityUtils' import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' +import { + ClerkOrganizationWebhookData, + ClerkMembershipWebhookData, + WebhookProcessingResult, +} from '@/types/webhooks' + +import { userService } from './userService' /** * Get a single organization by ID with security validation @@ -314,3 +321,295 @@ export async function isCurrentUserOrgAdmin( return false } } + +/** + * Gets an organization by Clerk ID + * @param {string} clerkId - Clerk organization ID + * @returns {Promise} Organization record or null if not found + */ +export async function getByClerkId( + clerkId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const organization = await pb + .collection('organizations') + .getFirstListItem(`clerkId="${clerkId}"`) + return organization as Organization + } catch (error) { + // If organization not found, return null instead of throwing + if (error instanceof Error && error.message.includes('404')) { + return null + } + console.error('Error fetching organization by clerk ID:', error) + return null + } +} + +/** + * Gets a membership by Clerk membership ID + * @param {string} clerkMembershipId - Clerk membership ID + * @returns {Promise} Membership record or null if not found + */ +export async function getMembershipByClerkId( + clerkMembershipId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const membership = await pb + .collection('organization_memberships') + .getFirstListItem(`clerkMembershipId="${clerkMembershipId}"`) + return membership as MembershipRecord + } catch (error) { + // If membership not found, return null instead of throwing + if (error instanceof Error && error.message.includes('404')) { + return null + } + console.error('Error fetching membership by clerk ID:', error) + return null + } +} + +/** + * Handles a webhook event for organization creation + * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookCreated( + data: ClerkOrganizationWebhookData, + elevated = true +): Promise { + try { + // Check if already exists + const existing = await getByClerkId(data.id) + if (existing) { + return { + message: `Organization ${data.id} already exists`, + success: true, + } + } + + // Create new organization + await createOrganization({ + clerkId: data.id, + name: data.name, + settings: { + imageUrl: data.image_url || null, + logoUrl: data.logo_url || null, + slug: data.slug || null, + }, + }) + + return { message: `Created organization ${data.id}`, success: true } + } catch (error) { + console.error('Failed to process organization creation webhook:', error) + return { + message: `Failed to process organization creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for organization update + * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookUpdated( + data: ClerkOrganizationWebhookData, + elevated = true +): Promise { + try { + // Find existing organization + const existing = await getByClerkId(data.id) + if (!existing) { + return { message: `Organization ${data.id} not found`, success: false } + } + + // Update organization + await updateOrganization(existing.id, { + name: data.name, + settings: { + ...existing.settings, + imageUrl: data.image_url || existing.settings?.imageUrl || null, + logoUrl: data.logo_url || existing.settings?.logoUrl || null, + slug: data.slug || existing.settings?.slug || null, + }, + }) + + return { message: `Updated organization ${data.id}`, success: true } + } catch (error) { + console.error('Failed to process organization update webhook:', error) + return { + message: `Failed to process organization update: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for organization deletion + * @param {ClerkOrganizationWebhookData} data - Organization deletion data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookDeleted( + data: ClerkOrganizationWebhookData, + elevated = true +): Promise { + try { + // Find existing organization + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `Organization ${data.id} already deleted or not found`, + success: true, + } + } + + // Delete organization + await deleteOrganization(existing.id) + + return { message: `Deleted organization ${data.id}`, success: true } + } catch (error) { + console.error('Failed to process organization deletion webhook:', error) + return { + message: `Failed to process organization deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles membership creation from webhook + * @param {ClerkMembershipWebhookData} data - Membership data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleMembershipWebhookCreated( + data: ClerkMembershipWebhookData, + elevated = true +): Promise { + try { + // Get organization and user IDs + const organization = await getByClerkId(data.organization.id) + if (!organization) { + return { + message: `Organization with Clerk ID ${data.organization.id} not found`, + success: false, + } + } + + const user = await userService.getByClerkId(data.public_user_data.user_id) + if (!user) { + return { + message: `User with Clerk ID ${data.public_user_data.user_id} not found`, + success: false, + } + } + + // Check if membership already exists + const existingMembership = await getMembershipByClerkId(data.id) + if (existingMembership) { + return { message: `Membership ${data.id} already exists`, success: true } + } + + // Create membership + await addMember( + { + clerkMembershipId: data.id, + organizationId: organization.id, + role: data.role, + userId: user.id, + }, + elevated + ) + + return { message: `Created membership ${data.id}`, success: true } + } catch (error) { + console.error('Failed to process membership creation webhook:', error) + return { + message: `Failed to process membership creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles membership update from webhook + * @param {ClerkMembershipWebhookData} data - Membership data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleMembershipWebhookUpdated( + data: ClerkMembershipWebhookData, + elevated = true +): Promise { + try { + // Check if membership exists + const existingMembership = await getMembershipByClerkId(data.id) + if (!existingMembership) { + return { message: `Membership ${data.id} not found`, success: false } + } + + // Update membership + await updateMembership( + existingMembership.id, + { + role: data.role, + }, + elevated + ) + + return { message: `Updated membership ${data.id}`, success: true } + } catch (error) { + console.error('Failed to process membership update webhook:', error) + return { + message: `Failed to process membership update: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles membership deletion from webhook + * @param {ClerkMembershipWebhookData} data - Membership data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleMembershipWebhookDeleted( + data: ClerkMembershipWebhookData, + elevated = true +): Promise { + try { + // Check if membership exists + const existingMembership = await getMembershipByClerkId(data.id) + if (!existingMembership) { + return { + message: `Membership ${data.id} already deleted or not found`, + success: true, + } + } + + // Delete membership + await removeMember(existingMembership.id, elevated) + + return { message: `Deleted membership ${data.id}`, success: true } + } catch (error) { + console.error('Failed to process membership deletion webhook:', error) + return { + message: `Failed to process membership deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} diff --git a/src/app/api/webhook/clerk/organization/route.ts b/src/app/api/webhook/clerk/organization/route.ts index 3e1d04c..fbc79ea 100644 --- a/src/app/api/webhook/clerk/organization/route.ts +++ b/src/app/api/webhook/clerk/organization/route.ts @@ -1,5 +1,5 @@ +import { createOrganization } from '@/app/actions/services/pocketbase/organizationService' import { verifyClerkWebhook } from '@/lib/webhookUtils' -import { log } from 'console' import { NextRequest, NextResponse } from 'next/server' /** @@ -18,8 +18,9 @@ export async function POST(req: NextRequest) { } try { - // Get the request body - const body = await req.json() + // Get the request body (clone request since body was consumed by verification) + const clone = req.clone() + const body = await clone.json() const { data, type } = body console.log(`Processing organization webhook: ${type}`) @@ -51,38 +52,95 @@ export async function POST(req: NextRequest) { /** * Handles organization creation event - * @param data - Organization data from Clerk webhook */ async function handleOrganizationCreated(data: any) { - const { id: clerkId, name } = data - console.log(data) + const { id: clerkId, logo_url, name, public_metadata, slug } = data - // TODO: Implement organization creation in your database - // Example: Create organization in PocketBase with the Clerk ID + console.log(`Organization created: ${clerkId} (${name})`) + + try { + await createOrganization({ + clerkId, + logoUrl: logo_url, + name, + publicMetadata: public_metadata || {}, + slug: slug || name.toLowerCase().replace(/\s+/g, '-'), + }) + + console.log(`Successfully created organization in PocketBase: ${clerkId}`) + } catch (error) { + console.error( + `Failed to create organization in PocketBase: ${clerkId}`, + error + ) + throw error + } } /** * Handles organization update event - * @param data - Organization data from Clerk webhook */ async function handleOrganizationUpdated(data: any) { const { id: clerkId, image_url, logo_url, name, public_metadata, slug } = data console.log(`Organization updated: ${clerkId} (${name})`) - // TODO: Implement organization update in your database - // Example: Update organization details in PocketBase + try { + const organization = await organizationService.findByClerkId(clerkId) + + if (!organization) { + console.error( + `Organization not found in PocketBase for clerkId: ${clerkId}` + ) + return + } + + await organizationService.updateOrganization(organization.id, { + imageUrl: image_url, + logoUrl: logo_url, + name, + publicMetadata: public_metadata || {}, + slug, + }) + + console.log(`Successfully updated organization in PocketBase: ${clerkId}`) + } catch (error) { + console.error( + `Failed to update organization in PocketBase: ${clerkId}`, + error + ) + throw error + } } /** * Handles organization deletion event - * @param data - Organization data from Clerk webhook */ async function handleOrganizationDeleted(data: any) { const { id: clerkId } = data console.log(`Organization deleted: ${clerkId}`) - // TODO: Implement organization deletion in your database - // Example: Mark organization as deleted in PocketBase + try { + const organization = await organizationService.findByClerkId(clerkId) + + if (!organization) { + console.log( + `Organization not found in PocketBase for clerkId: ${clerkId}` + ) + return + } + + await organizationService.softDeleteOrganization(organization.id) + + console.log( + `Successfully marked organization as deleted in PocketBase: ${clerkId}` + ) + } catch (error) { + console.error( + `Failed to mark organization as deleted in PocketBase: ${clerkId}`, + error + ) + throw error + } } diff --git a/src/app/api/webhook/clerk/route.ts b/src/app/api/webhook/clerk/route.ts new file mode 100644 index 0000000..38151e5 --- /dev/null +++ b/src/app/api/webhook/clerk/route.ts @@ -0,0 +1,54 @@ +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' +import { headers } from 'next/headers' +import { NextResponse } from 'next/server' +import { Webhook } from 'svix' + +/** + * Webhook handler for Clerk events + * Verifies the webhook signature and processes events + */ +export async function POST(req: Request) { + const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET + + if (!WEBHOOK_SECRET) { + console.error('Missing CLERK_WEBHOOK_SECRET') + return new NextResponse('Webhook secret missing', { status: 500 }) + } + + // Get the signature and timestamp from the headers + const headerPayload = headers() + const svix_id = headerPayload.get('svix-id') + const svix_timestamp = headerPayload.get('svix-timestamp') + const svix_signature = headerPayload.get('svix-signature') + + // If there are no headers, error out + if (!svix_id || !svix_timestamp || !svix_signature) { + return new NextResponse('Error: Missing svix headers', { status: 400 }) + } + + // Get the body + const payload = await req.json() + const body = JSON.stringify(payload) + + // Create a new Svix instance with your secret + const webhook = new Webhook(WEBHOOK_SECRET) + + try { + // Verify the payload with the headers + const event = webhook.verify(body, { + 'svix-id': svix_id, + 'svix-signature': svix_signature, + 'svix-timestamp': svix_timestamp, + }) + + console.log(`Webhook received: ${event.type}`) + + // Process the event using our centralized handler + const result = await processWebhookEvent(event) + + return NextResponse.json(result) + } catch (err) { + console.error('Error verifying webhook:', err) + return new NextResponse('Error verifying webhook', { status: 400 }) + } +} diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index c188d30..d6a3af4 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -1,3 +1,4 @@ +import { userService } from '@/app/actions/services/pocketbase/userService' import { verifyClerkWebhook } from '@/lib/webhookUtils' import { NextRequest, NextResponse } from 'next/server' @@ -18,7 +19,8 @@ export async function POST(req: NextRequest) { try { // Get the request body - const body = await req.json() + const clone = req.clone() + const body = await clone.json() const { data, type } = body console.log(`Processing user webhook: ${type}`) @@ -52,20 +54,80 @@ export async function POST(req: NextRequest) { * Handles user creation event */ async function handleUserCreated(data: any) { - const { id: clerkId } = data - console.log(`User created: ${clerkId}`) + const { + email_addresses, + first_name, + id: clerkId, + last_name, + profile_image_url, + public_metadata, + } = data + + // Find primary email + const primaryEmail = email_addresses.find( + (email: any) => email.id === data.primary_email_address_id + )?.email_address - // TODO: Create user in your database + console.log(`User created: ${clerkId} (${primaryEmail})`) + + try { + await userService.createUser({ + clerkId, + email: primaryEmail, + firstName: first_name || '', + lastName: last_name || '', + profileImageUrl: profile_image_url, + publicMetadata: public_metadata || {}, + }) + + console.log(`Successfully created user in PocketBase: ${clerkId}`) + } catch (error) { + console.error(`Failed to create user in PocketBase: ${clerkId}`, error) + throw error + } } /** * Handles user update event */ async function handleUserUpdated(data: any) { - const { id: clerkId } = data - console.log(`User updated: ${clerkId}`) + const { + email_addresses, + first_name, + id: clerkId, + last_name, + profile_image_url, + public_metadata, + } = data + + // Find primary email + const primaryEmail = email_addresses.find( + (email: any) => email.id === data.primary_email_address_id + )?.email_address + + console.log(`User updated: ${clerkId} (${primaryEmail})`) + + try { + const user = await userService.findByClerkId(clerkId) + + if (!user) { + console.error(`User not found in PocketBase for clerkId: ${clerkId}`) + return + } + + await userService.updateUser(user.id, { + email: primaryEmail, + firstName: first_name || '', + lastName: last_name || '', + profileImageUrl: profile_image_url, + publicMetadata: public_metadata || {}, + }) - // TODO: Update user in your database + console.log(`Successfully updated user in PocketBase: ${clerkId}`) + } catch (error) { + console.error(`Failed to update user in PocketBase: ${clerkId}`, error) + throw error + } } /** @@ -73,7 +135,25 @@ async function handleUserUpdated(data: any) { */ async function handleUserDeleted(data: any) { const { id: clerkId } = data + console.log(`User deleted: ${clerkId}`) - // TODO: Handle user deletion in your database + try { + const user = await userService.findByClerkId(clerkId) + + if (!user) { + console.log(`User not found in PocketBase for clerkId: ${clerkId}`) + return + } + + await userService.softDeleteUser(user.id) + + console.log(`Successfully marked user as deleted in PocketBase: ${clerkId}`) + } catch (error) { + console.error( + `Failed to mark user as deleted in PocketBase: ${clerkId}`, + error + ) + throw error + } } diff --git a/src/types/webhooks.ts b/src/types/webhooks.ts new file mode 100644 index 0000000..81cbe3a --- /dev/null +++ b/src/types/webhooks.ts @@ -0,0 +1,64 @@ +/** + * Types for webhook events and data + */ +import { WebhookEvent } from "@clerk/nextjs/server"; + +// Clerk Organization Webhook Data +export interface ClerkOrganizationWebhookData { + id: string; + name: string; + slug?: string; + logo_url?: string; + image_url?: string; + created_at?: number; + updated_at?: number; + created_by?: string; + public_metadata?: Record; + deleted?: boolean; +} + +// Clerk Organization Membership Webhook Data +export interface ClerkMembershipWebhookData { + id: string; + role: string; + created_at?: number; + updated_at?: number; + organization: { + id: string; + name: string; + slug?: string; + }; + public_user_data: { + user_id: string; + first_name?: string; + last_name?: string; + identifier: string; + image_url?: string; + profile_image_url?: string; + }; + deleted?: boolean; +} + +// Clerk User Webhook Data +export interface ClerkUserWebhookData { + id: string; + email_addresses?: Array<{ + email_address: string; + id: string; + }>; + first_name?: string; + last_name?: string; + profile_image_url?: string; + image_url?: string; + primary_email_address_id?: string; + deleted?: boolean; +} + +// Webhook Processing Result +export interface WebhookProcessingResult { + success: boolean; + message: string; +} + +// Webhook Handler Function Type +export type WebhookHandler = (event: WebhookEvent) => Promise; \ No newline at end of file From 6361f3b46420c7c353e0502c3e10f4b4bcd60198 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 17:44:51 +0200 Subject: [PATCH 23/73] refactor(api): improve record data type handling - Introduce RecordData type for better data structure - Update createRecord and updateRecord to use RecordData - Keep elevated parameter for future permission checks --- .../actions/services/pocketbase/baseService.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/actions/services/pocketbase/baseService.ts b/src/app/actions/services/pocketbase/baseService.ts index a94a6b7..f0233e7 100644 --- a/src/app/actions/services/pocketbase/baseService.ts +++ b/src/app/actions/services/pocketbase/baseService.ts @@ -54,6 +54,11 @@ export const handlePocketBaseError = ( throw new Error(`Unknown PocketBase error${contextMsg}`) } +/** + * Type for record data + */ +export type RecordData = Record + /** * Base method for creating records with permission handling * @param collection - Collection name @@ -62,7 +67,9 @@ export const handlePocketBaseError = ( */ export async function createRecord( collection: string, - data: any, + data: RecordData, + // We keep the elevated param for future implementation of permission checks + // eslint-disable-next-line @typescript-eslint/no-unused-vars elevated = false ) { try { @@ -89,7 +96,9 @@ export async function createRecord( export async function updateRecord( collection: string, id: string, - data: any, + data: RecordData, + // We keep the elevated param for future implementation of permission checks + // eslint-disable-next-line @typescript-eslint/no-unused-vars elevated = false ) { try { @@ -115,6 +124,8 @@ export async function updateRecord( export async function deleteRecord( collection: string, id: string, + // We keep the elevated param for future implementation of permission checks + // eslint-disable-next-line @typescript-eslint/no-unused-vars elevated = false ) { try { From fb56bb187833b42e23f035925f663b6b0f8e5d97 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 17:57:06 +0200 Subject: [PATCH 24/73] feat(api): enhance organization management features - Add internal methods for creating, updating, and deleting organizations - Implement user management functions to add/remove users from organizations - Update organization schema in diagram with new ActivityLog entity - Improve security checks for organization access and member management - Refactor existing API methods to support elevated access scenarios - Enhance webhook handlers for organization and membership events --- .cursor/rules/rules-diagram-mermaid.mdc | 168 ++--- .../pocketbase/organizationService.ts | 673 +++++++++++++----- 2 files changed, 584 insertions(+), 257 deletions(-) diff --git a/.cursor/rules/rules-diagram-mermaid.mdc b/.cursor/rules/rules-diagram-mermaid.mdc index f5a2f75..59af135 100644 --- a/.cursor/rules/rules-diagram-mermaid.mdc +++ b/.cursor/rules/rules-diagram-mermaid.mdc @@ -4,95 +4,97 @@ globs: alwaysApply: true --- erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} + Organization { + string id PK + string name + string email + string phone + string address + json settings + string clerkId + string stripeCustomerId + string subscriptionId + string subscriptionStatus + string priceId + date created + date updated + } -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} + User { + string id PK + string name + string email + string phone + string role + boolean isAdmin + boolean verified + boolean emailVisibility + string clerkId + file avatar + date created + date updated + } -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} + Equipment { + string id PK + string name + string qrNfcCode + string tags + editor notes + date acquisitionDate + date created + date updated + } -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} + Project { + string id PK + string name + string address + editor notes + date startDate + date endDate + date created + date updated + } -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} + Assignment { + string id PK + date startDate + date endDate + editor notes + date created + date updated + } -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} + Image { + string id PK + string title + string alt + string caption + file image + date created + date updated + } -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees + ActivityLog { + string id PK + json metadata + date created + date updated + } -User }o--o{ Assignment : "is assigned to" + Organization ||--o{ User : "has" + Organization ||--o{ Equipment : "owns" + Organization ||--o{ Project : "manages" + Organization ||--o{ Assignment : "oversees" + Organization ||--o{ ActivityLog : "tracks" -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" + User }o--o{ Organization : "belongs to" + User }o--o{ Assignment : "is assigned to" + User }o--o{ ActivityLog : "generates" -Project }o--o{ Assignment : includes + Equipment }o--o{ Assignment : "is assigned via" + Equipment }o--o{ Equipment : "parent/child" + Equipment }o--o{ ActivityLog : "is tracked in" + + Project }o--o{ Assignment : "includes" diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts index 7ffa666..d415958 100644 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ b/src/app/actions/services/pocketbase/organizationService.ts @@ -3,6 +3,7 @@ import { getPocketBase, handlePocketBaseError, + RecordData, } from '@/app/actions/services/pocketbase/baseService' import { validateCurrentUser, @@ -10,14 +11,176 @@ import { PermissionLevel, SecurityError, } from '@/app/actions/services/pocketbase/securityUtils' -import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' +import { userService } from '@/app/actions/services/pocketbase/userService' +import { + Organization, + User, + ListOptions, + ListResult, +} from '@/types/types_pocketbase' import { ClerkOrganizationWebhookData, ClerkMembershipWebhookData, WebhookProcessingResult, } from '@/types/webhooks' -import { userService } from './userService' +// ============================ +// Internal methods (no security checks) +// ============================ + +/** + * Internal: Create organization without security checks + * @param data Organization data + */ +async function _createOrganization( + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('organizations').create(data) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._createOrganization' + ) + } +} + +/** + * Internal: Update organization without security checks + * @param id Organization ID + * @param data Updated organization data + */ +async function _updateOrganization( + id: string, + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('organizations').update(id, data) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._updateOrganization' + ) + } +} + +/** + * Internal: Delete organization without security checks + * @param id Organization ID + */ +async function _deleteOrganization(id: string): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('organizations').delete(id) + return true + } catch (error) { + handlePocketBaseError(error, 'OrganizationService._deleteOrganization') + return false + } +} + +/** + * Internal: Add user to organization without security checks + * @param userId User ID + * @param organizationId Organization ID + */ +async function _addUserToOrganization( + userId: string, + organizationId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Get the user + const user = await pb.collection('users').getOne(userId, { + expand: 'organizations', + }) + + // Get current organizations + let currentOrgs = user.organizations || [] + if (typeof currentOrgs === 'string') { + currentOrgs = [currentOrgs] + } + + // Check if user is already in organization + if (!currentOrgs.includes(organizationId)) { + // Add organization to user's organizations list + currentOrgs.push(organizationId) + } + + // Update user with new organizations list + return await pb.collection('users').update(userId, { + organizations: currentOrgs, + }) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._addUserToOrganization' + ) + } +} + +/** + * Internal: Remove user from organization without security checks + * @param userId User ID + * @param organizationId Organization ID + */ +async function _removeUserFromOrganization( + userId: string, + organizationId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Get the user + const user = await pb.collection('users').getOne(userId, { + expand: 'organizations', + }) + + // Get current organizations + let currentOrgs = user.organizations || [] + if (typeof currentOrgs === 'string') { + currentOrgs = [currentOrgs] + } + + // Remove organization from user's organizations list + const updatedOrgs = currentOrgs.filter(orgId => orgId !== organizationId) + + // Update user with new organizations list + return await pb.collection('users').update(userId, { + organizations: updatedOrgs, + }) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._removeUserFromOrganization' + ) + } +} + +// ============================ +// Public API methods (with security) +// ============================ /** * Get a single organization by ID with security validation @@ -49,8 +212,7 @@ export async function getOrganizationByClerkId( clerkId: string ): Promise { try { - // This endpoint is typically called during authentication - // We still validate the current user is authenticated + // Validate current user is authenticated const user = await validateCurrentUser() const pb = await getPocketBase() @@ -58,31 +220,27 @@ export async function getOrganizationByClerkId( throw new Error('Failed to connect to PocketBase') } - // Fixed the template literal syntax const organization = await pb .collection('organizations') - .getFirstListItem(`clerkId=${clerkId}`) + .getFirstListItem(`clerkId="${clerkId}"`) - // After fetching, verify that the user belongs to this organization - // The user can have multiple organizations, so we need to check if the requested org - // is in their list of organizations - if ( - !user.expand?.organizationId || - !Array.isArray(user.expand.organizationId) - ) { - throw new SecurityError('User has no associated organizations') - } + // Check if user has access to this organization + // Get the user with expanded organizations + const userWithOrgs = await pb.collection('users').getOne(user.id, { + expand: 'organizations', + }) - // Check if the requested organization is in the user's list of organizations - const hasAccess = user.expand.organizationId.some( - org => org.id === organization.id - ) + // Check if the user has access to this organization + const hasAccess = + userWithOrgs.organizations && + userWithOrgs.organizations.some( + (orgId: string) => orgId === organization.id + ) if (!hasAccess) { throw new SecurityError('User does not belong to this organization') } - // todo: fix type return organization } catch (error) { if (error instanceof SecurityError) { @@ -96,40 +254,27 @@ export async function getOrganizationByClerkId( } /** - * Get organizations list with pagination for the current user + * Get organizations list for the current user */ export async function getUserOrganizations(): Promise { try { const user = await validateCurrentUser() - // If the user's organizations are already expanded, return them - if ( - user.expand?.organizationId && - Array.isArray(user.expand.organizationId) - ) { - return user.expand.organizationId - } - - // Otherwise, we need to fetch them const pb = await getPocketBase() if (!pb) { throw new Error('Failed to connect to PocketBase') } - // Assuming there's a relation field in the users collection that points to organizations // Fetch the user with expanded organizations const userWithOrgs = await pb.collection('users').getOne(user.id, { - expand: 'organizationId', + expand: 'organizations', }) - if ( - userWithOrgs.expand?.organizationId && - Array.isArray(userWithOrgs.expand.organizationId) - ) { - return userWithOrgs.expand.organizationId + if (!userWithOrgs.expand?.organizations) { + return [] } - return [] + return userWithOrgs.expand.organizations } catch (error) { if (error instanceof SecurityError) { throw error @@ -141,30 +286,108 @@ export async function getUserOrganizations(): Promise { } } +/** + * Get all users in an organization + */ +export async function getOrganizationUsers( + organizationId: string +): Promise { + try { + // Security check - validates user has access to this organization + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with this organization in their organizations list + const result = await pb.collection('users').getList(1, 100, { + filter: `organizations ~ "${organizationId}"`, + }) + + return result.items + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getOrganizationUsers' + ) + } +} + /** * Get organizations list with pagination - * This should only be accessible to super-admins, so we don't implement it - * in a regular multi-tenant app + * This should only be accessible to super-admins in regular operation */ export async function getOrganizationsList( - options: ListOptions = {} + options: ListOptions = {}, + elevated = false ): Promise> { - // This function should be restricted to super-admins only - throw new SecurityError( - 'This operation is restricted to super administrators' - ) + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError( + 'This operation is restricted to super administrators' + ) + } + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb + .collection('organizations') + .getList(options.page || 1, options.perPage || 50, { + filter: options.filter, + sort: options.sort, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getOrganizationsList' + ) + } } /** - * Create a new organization - * This should only be done during onboarding or by super-admins + * Create a new organization - supports both regular and elevated access */ export async function createOrganization( - data: Partial + data: Partial, + elevated = false ): Promise { - // For creating organizations, we typically handle this specially - // during onboarding with Clerk. This should not be exposed to regular users. - throw new SecurityError('This operation is restricted') + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError( + 'This operation is restricted to super administrators' + ) + } + } + + // For elevated access (webhooks), we bypass additional security checks + return await _createOrganization(data) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.createOrganization' + ) + } } /** @@ -172,31 +395,31 @@ export async function createOrganization( */ export async function updateOrganization( id: string, - data: Partial + data: Partial, + elevated = false ): Promise { try { - // Security check - requires ADMIN permission for organization updates - await validateOrganizationAccess(id, PermissionLevel.ADMIN) + if (!elevated) { + // Security check - requires ADMIN permission for organization updates + await validateOrganizationAccess(id, PermissionLevel.ADMIN) - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + // Sanitize sensitive fields for regular users + const sanitizedData = { ...data } - // Sanitize sensitive fields - const sanitizedData = { ...data } + // Never allow changing the clerkId - that's a special binding + delete sanitizedData.clerkId - // Never allow changing the clerkId - that's a special binding - delete sanitizedData.clerkId + // Don't allow changing Stripe-related fields directly + delete sanitizedData.stripeCustomerId + delete sanitizedData.subscriptionId + delete sanitizedData.subscriptionStatus + delete sanitizedData.priceId - // Don't allow changing Stripe-related fields directly - // These should only be updated by the Stripe webhook - delete sanitizedData.stripeCustomerId - delete sanitizedData.subscriptionId - delete sanitizedData.subscriptionStatus - delete sanitizedData.priceId + return await _updateOrganization(id, sanitizedData) + } - return await pb.collection('organizations').update(id, sanitizedData) + // For elevated access, use the data as provided + return await _updateOrganization(id, data) } catch (error) { if (error instanceof SecurityError) { throw error @@ -209,13 +432,32 @@ export async function updateOrganization( } /** - * Delete an organization - * This should only be accessible to super-admins or during account cancellation flows + * Delete an organization - supports both regular and elevated access */ -export async function deleteOrganization(id: string): Promise { - // This function should be restricted to super-admins only - // or be part of a special account cancellation flow - throw new SecurityError('This operation is restricted') +export async function deleteOrganization( + id: string, + elevated = false +): Promise { + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError( + 'This operation is restricted to super administrators' + ) + } + } + + // For elevated access (webhooks), we bypass additional security checks + return await _deleteOrganization(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + handlePocketBaseError(error, 'OrganizationService.deleteOrganization') + return false + } } /** @@ -229,15 +471,17 @@ export async function updateSubscription( subscriptionId?: string subscriptionStatus?: string priceId?: string - } + }, + elevated = false ): Promise { try { - // This function should verify it's being called from a valid webhook - // For demo purposes, we'll implement a basic check - // In production, you'd add a webhook secret validation - - // We'll skip full security checks since this is called from webhooks - // but we still validate the organization exists + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError('This operation is restricted') + } + } const pb = await getPocketBase() if (!pb) { @@ -252,6 +496,9 @@ export async function updateSubscription( return await pb.collection('organizations').update(id, subscriptionData) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError( error, 'OrganizationService.updateSubscription' @@ -261,7 +508,7 @@ export async function updateSubscription( /** * Get current organization settings for the authenticated user - * If the user belongs to multiple organizations, takes the first active one or prompts selection + * If the user belongs to multiple organizations, takes the first active one */ export async function getCurrentOrganizationSettings(): Promise { try { @@ -273,7 +520,6 @@ export async function getCurrentOrganizationSettings(): Promise { } // For simplicity, we're returning the first organization - // In a real application, you might want to use the last selected org or prompt for selection const firstOrgId = userOrganizations[0].id // Fetch full organization details with validated access @@ -302,10 +548,7 @@ export async function isCurrentUserOrgAdmin( // Check if user has admin role const isAdmin = user.isAdmin || user.role === 'admin' - // If they're not an admin by role, we need to check if they're an admin of this specific org if (!isAdmin) { - // This would need additional checks in a real application - // For example, checking a userOrganizationRole table return false } @@ -322,10 +565,62 @@ export async function isCurrentUserOrgAdmin( } } +/** + * Add a user to an organization + */ +export async function addUserToOrganization( + userId: string, + organizationId: string, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for member management + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + } + + return await _addUserToOrganization(userId, organizationId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.addUserToOrganization' + ) + } +} + +/** + * Remove a user from an organization + */ +export async function removeUserFromOrganization( + userId: string, + organizationId: string, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for member management + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + } + + return await _removeUserFromOrganization(userId, organizationId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.removeUserFromOrganization' + ) + } +} + /** * Gets an organization by Clerk ID * @param {string} clerkId - Clerk organization ID - * @returns {Promise} Organization record or null if not found + * @returns {Promise} Organization record or null if not found */ export async function getByClerkId( clerkId: string @@ -338,7 +633,7 @@ export async function getByClerkId( const organization = await pb .collection('organizations') - .getFirstListItem(`clerkId="${clerkId}"`) + .getFirstListItem(`clerkId=${clerkId}`) return organization as Organization } catch (error) { // If organization not found, return null instead of throwing @@ -350,33 +645,9 @@ export async function getByClerkId( } } -/** - * Gets a membership by Clerk membership ID - * @param {string} clerkMembershipId - Clerk membership ID - * @returns {Promise} Membership record or null if not found - */ -export async function getMembershipByClerkId( - clerkMembershipId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const membership = await pb - .collection('organization_memberships') - .getFirstListItem(`clerkMembershipId="${clerkMembershipId}"`) - return membership as MembershipRecord - } catch (error) { - // If membership not found, return null instead of throwing - if (error instanceof Error && error.message.includes('404')) { - return null - } - console.error('Error fetching membership by clerk ID:', error) - return null - } -} +// ============================ +// Webhook handler methods +// ============================ /** * Handles a webhook event for organization creation @@ -399,17 +670,23 @@ export async function handleWebhookCreated( } // Create new organization - await createOrganization({ - clerkId: data.id, - name: data.name, - settings: { - imageUrl: data.image_url || null, - logoUrl: data.logo_url || null, - slug: data.slug || null, + await createOrganization( + { + clerkId: data.id, + name: data.name, + settings: { + imageUrl: data.image_url || null, + logoUrl: data.logo_url || null, + slug: data.slug || null, + }, }, - }) + elevated + ) - return { message: `Created organization ${data.id}`, success: true } + return { + message: `Created organization ${data.id}`, + success: true, + } } catch (error) { console.error('Failed to process organization creation webhook:', error) return { @@ -433,21 +710,31 @@ export async function handleWebhookUpdated( // Find existing organization const existing = await getByClerkId(data.id) if (!existing) { - return { message: `Organization ${data.id} not found`, success: false } + return { + message: `Organization ${data.id} not found`, + success: false, + } } // Update organization - await updateOrganization(existing.id, { - name: data.name, - settings: { - ...existing.settings, - imageUrl: data.image_url || existing.settings?.imageUrl || null, - logoUrl: data.logo_url || existing.settings?.logoUrl || null, - slug: data.slug || existing.settings?.slug || null, + await updateOrganization( + existing.id, + { + name: data.name, + settings: { + ...existing.settings, + imageUrl: data.image_url || existing.settings?.imageUrl || null, + logoUrl: data.logo_url || existing.settings?.logoUrl || null, + slug: data.slug || existing.settings?.slug || null, + }, }, - }) + elevated + ) - return { message: `Updated organization ${data.id}`, success: true } + return { + message: `Updated organization ${data.id}`, + success: true, + } } catch (error) { console.error('Failed to process organization update webhook:', error) return { @@ -478,9 +765,12 @@ export async function handleWebhookDeleted( } // Delete organization - await deleteOrganization(existing.id) + await deleteOrganization(existing.id, elevated) - return { message: `Deleted organization ${data.id}`, success: true } + return { + message: `Deleted organization ${data.id}`, + success: true, + } } catch (error) { console.error('Failed to process organization deletion webhook:', error) return { @@ -501,7 +791,7 @@ export async function handleMembershipWebhookCreated( elevated = true ): Promise { try { - // Get organization and user IDs + // Get organization and user const organization = await getByClerkId(data.organization.id) if (!organization) { return { @@ -518,24 +808,24 @@ export async function handleMembershipWebhookCreated( } } - // Check if membership already exists - const existingMembership = await getMembershipByClerkId(data.id) - if (existingMembership) { - return { message: `Membership ${data.id} already exists`, success: true } - } + // Add user to organization + await addUserToOrganization(user.id, organization.id, elevated) - // Create membership - await addMember( - { - clerkMembershipId: data.id, - organizationId: organization.id, - role: data.role, - userId: user.id, - }, - elevated - ) + // Update user role if needed + if (data.role === 'admin') { + await userService.updateUser( + user.id, + { + role: 'admin', + }, + elevated + ) + } - return { message: `Created membership ${data.id}`, success: true } + return { + message: `Added user ${user.id} to organization ${organization.id}`, + success: true, + } } catch (error) { console.error('Failed to process membership creation webhook:', error) return { @@ -556,22 +846,46 @@ export async function handleMembershipWebhookUpdated( elevated = true ): Promise { try { - // Check if membership exists - const existingMembership = await getMembershipByClerkId(data.id) - if (!existingMembership) { - return { message: `Membership ${data.id} not found`, success: false } + // Get organization and user + const organization = await getByClerkId(data.organization.id) + if (!organization) { + return { + message: `Organization with Clerk ID ${data.organization.id} not found`, + success: false, + } } - // Update membership - await updateMembership( - existingMembership.id, - { - role: data.role, - }, - elevated - ) + const user = await userService.getByClerkId(data.public_user_data.user_id) + if (!user) { + return { + message: `User with Clerk ID ${data.public_user_data.user_id} not found`, + success: false, + } + } + + // Update user role if needed + if (data.role === 'admin') { + await userService.updateUser( + user.id, + { + role: 'admin', + }, + elevated + ) + } else if (data.role === 'basic_member') { + await userService.updateUser( + user.id, + { + role: 'member', + }, + elevated + ) + } - return { message: `Updated membership ${data.id}`, success: true } + return { + message: `Updated user ${user.id} role in organization ${organization.id}`, + success: true, + } } catch (error) { console.error('Failed to process membership update webhook:', error) return { @@ -592,19 +906,30 @@ export async function handleMembershipWebhookDeleted( elevated = true ): Promise { try { - // Check if membership exists - const existingMembership = await getMembershipByClerkId(data.id) - if (!existingMembership) { + // Get organization and user + const organization = await getByClerkId(data.organization.id) + if (!organization) { + return { + message: `Organization with Clerk ID ${data.organization.id} not found`, + success: true, + } + } + + const user = await userService.getByClerkId(data.public_user_data.user_id) + if (!user) { return { - message: `Membership ${data.id} already deleted or not found`, + message: `User with Clerk ID ${data.public_user_data.user_id} not found`, success: true, } } - // Delete membership - await removeMember(existingMembership.id, elevated) + // Remove user from organization + await removeUserFromOrganization(user.id, organization.id, elevated) - return { message: `Deleted membership ${data.id}`, success: true } + return { + message: `Removed user ${user.id} from organization ${organization.id}`, + success: true, + } } catch (error) { console.error('Failed to process membership deletion webhook:', error) return { From 2cb866d1a621b7ac0ae1b350c1e982a20dc80d63 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 18:04:48 +0200 Subject: [PATCH 25/73] feat(organization): implement core organization management features - Add functions for creating, updating, and deleting organizations - Implement security validations for user access to organizations - Create methods to manage organization memberships and retrieve users - Set up webhook handlers for organization events from Clerk - Introduce internal methods without security checks for direct access when needed --- .../services/pocketbase/organization/core.ts | 344 +++++++ .../services/pocketbase/organization/index.ts | 39 + .../pocketbase/organization/internal.ts | 106 ++ .../pocketbase/organization/membership.ts | 185 ++++ .../pocketbase/organization/security.ts | 43 + .../organization/webhook-handlers.ts | 310 ++++++ .../pocketbase/organizationService.ts | 940 ------------------ 7 files changed, 1027 insertions(+), 940 deletions(-) create mode 100644 src/app/actions/services/pocketbase/organization/core.ts create mode 100644 src/app/actions/services/pocketbase/organization/index.ts create mode 100644 src/app/actions/services/pocketbase/organization/internal.ts create mode 100644 src/app/actions/services/pocketbase/organization/membership.ts create mode 100644 src/app/actions/services/pocketbase/organization/security.ts create mode 100644 src/app/actions/services/pocketbase/organization/webhook-handlers.ts delete mode 100644 src/app/actions/services/pocketbase/organizationService.ts diff --git a/src/app/actions/services/pocketbase/organization/core.ts b/src/app/actions/services/pocketbase/organization/core.ts new file mode 100644 index 0000000..522e952 --- /dev/null +++ b/src/app/actions/services/pocketbase/organization/core.ts @@ -0,0 +1,344 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' + +import { + _createOrganization, + _updateOrganization, + _deleteOrganization, +} from './internal' + +/** + * Core organization operations with security validations + */ + +/** + * Get a single organization by ID with security validation + */ +export async function getOrganization(id: string): Promise { + try { + // Security check - validates user has access to this organization + await validateOrganizationAccess(id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('organizations').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'OrganizationService.getOrganization') + } +} + +/** + * Get an organization by Clerk ID with security validation + * This is primarily used during authentication + */ +export async function getOrganizationByClerkId( + clerkId: string +): Promise { + try { + // Validate current user is authenticated + const user = await validateCurrentUser() + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const organization = await pb + .collection('organizations') + .getFirstListItem(`clerkId="${clerkId}"`) + + // Check if user has access to this organization + // Get the user with expanded organizations + const userWithOrgs = await pb.collection('users').getOne(user.id, { + expand: 'organizations', + }) + + // Check if the user has access to this organization + const hasAccess = + userWithOrgs.organizations && + userWithOrgs.organizations.some( + (orgId: string) => orgId === organization.id + ) + + if (!hasAccess) { + throw new SecurityError('User does not belong to this organization') + } + + return organization + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getOrganizationByClerkId' + ) + } +} + +/** + * Get organizations list for the current user + */ +export async function getUserOrganizations(): Promise { + try { + const user = await validateCurrentUser() + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fetch the user with expanded organizations + const userWithOrgs = await pb.collection('users').getOne(user.id, { + expand: 'organizations', + }) + + if (!userWithOrgs.expand?.organizations) { + return [] + } + + return userWithOrgs.expand.organizations + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getUserOrganizations' + ) + } +} + +/** + * Get organizations list with pagination + * This should only be accessible to super-admins in regular operation + */ +export async function getOrganizationsList( + options: ListOptions = {}, + elevated = false +): Promise> { + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError( + 'This operation is restricted to super administrators' + ) + } + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb + .collection('organizations') + .getList(options.page || 1, options.perPage || 50, { + filter: options.filter, + sort: options.sort, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getOrganizationsList' + ) + } +} + +/** + * Create a new organization - supports both regular and elevated access + */ +export async function createOrganization( + data: Partial, + elevated = false +): Promise { + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError( + 'This operation is restricted to super administrators' + ) + } + } + + // For elevated access (webhooks), we bypass additional security checks + return await _createOrganization(data) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.createOrganization' + ) + } +} + +/** + * Update an organization with security validation + */ +export async function updateOrganization( + id: string, + data: Partial, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for organization updates + await validateOrganizationAccess(id, PermissionLevel.ADMIN) + + // Sanitize sensitive fields for regular users + const sanitizedData = { ...data } + + // Never allow changing the clerkId - that's a special binding + delete sanitizedData.clerkId + + // Don't allow changing Stripe-related fields directly + delete sanitizedData.stripeCustomerId + delete sanitizedData.subscriptionId + delete sanitizedData.subscriptionStatus + delete sanitizedData.priceId + + return await _updateOrganization(id, sanitizedData) + } + + // For elevated access, use the data as provided + return await _updateOrganization(id, data) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.updateOrganization' + ) + } +} + +/** + * Delete an organization - supports both regular and elevated access + */ +export async function deleteOrganization( + id: string, + elevated = false +): Promise { + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError( + 'This operation is restricted to super administrators' + ) + } + } + + // For elevated access (webhooks), we bypass additional security checks + return await _deleteOrganization(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + handlePocketBaseError(error, 'OrganizationService.deleteOrganization') + return false + } +} + +/** + * Update organization subscription details + * This should only be called from Stripe webhooks, not directly by users + */ +export async function updateSubscription( + id: string, + subscriptionData: { + stripeCustomerId?: string + subscriptionId?: string + subscriptionStatus?: string + priceId?: string + }, + elevated = false +): Promise { + try { + if (!elevated) { + // For regular access, verify super-admin status + const user = await validateCurrentUser() + if (!user.isAdmin) { + throw new SecurityError('This operation is restricted') + } + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Verify the organization exists + const organization = await pb.collection('organizations').getOne(id) + if (!organization) { + throw new Error('Organization not found') + } + + return await pb.collection('organizations').update(id, subscriptionData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.updateSubscription' + ) + } +} + +/** + * Get current organization settings for the authenticated user + * If the user belongs to multiple organizations, takes the first active one + */ +export async function getCurrentOrganizationSettings(): Promise { + try { + // Get all organizations for the current user + const userOrganizations = await getUserOrganizations() + + if (!userOrganizations.length) { + throw new SecurityError('User does not belong to any organization') + } + + // For simplicity, we're returning the first organization + const firstOrgId = userOrganizations[0].id + + // Fetch full organization details with validated access + return await getOrganization(firstOrgId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getCurrentOrganizationSettings' + ) + } +} diff --git a/src/app/actions/services/pocketbase/organization/index.ts b/src/app/actions/services/pocketbase/organization/index.ts new file mode 100644 index 0000000..98d735b --- /dev/null +++ b/src/app/actions/services/pocketbase/organization/index.ts @@ -0,0 +1,39 @@ +/** + * Organization service - public exports + */ + +// Core operations +export { + getOrganization, + getOrganizationByClerkId, + getUserOrganizations, + getOrganizationsList, + createOrganization, + updateOrganization, + deleteOrganization, + updateSubscription, + getCurrentOrganizationSettings, +} from './core' + +// Membership functions +export { + addUserToOrganization, + removeUserFromOrganization, + getOrganizationUsers, +} from './membership' + +// Security utilities +export { isCurrentUserOrgAdmin } from './security' + +// Webhook handlers +export { + handleWebhookCreated, + handleWebhookUpdated, + handleWebhookDeleted, + handleMembershipWebhookCreated, + handleMembershipWebhookUpdated, + handleMembershipWebhookDeleted, +} from './webhook-handlers' + +// Internal functions for direct access when needed +export { getByClerkId } from './internal' diff --git a/src/app/actions/services/pocketbase/organization/internal.ts b/src/app/actions/services/pocketbase/organization/internal.ts new file mode 100644 index 0000000..d4ab101 --- /dev/null +++ b/src/app/actions/services/pocketbase/organization/internal.ts @@ -0,0 +1,106 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { Organization, User } from '@/types/types_pocketbase' + +/** + * Internal methods for organization management + * These methods have no security checks and should only be called + * from secured public API methods + */ + +/** + * Internal: Create organization without security checks + * @param data Organization data + */ +export async function _createOrganization( + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('organizations').create(data) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._createOrganization' + ) + } +} + +/** + * Internal: Update organization without security checks + * @param id Organization ID + * @param data Updated organization data + */ +export async function _updateOrganization( + id: string, + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('organizations').update(id, data) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._updateOrganization' + ) + } +} + +/** + * Internal: Delete organization without security checks + * @param id Organization ID + */ +export async function _deleteOrganization(id: string): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('organizations').delete(id) + return true + } catch (error) { + handlePocketBaseError(error, 'OrganizationService._deleteOrganization') + return false + } +} + +/** + * Gets an organization by Clerk ID + * @param {string} clerkId - Clerk organization ID + * @returns {Promise} Organization record or null if not found + */ +export async function getByClerkId( + clerkId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const organization = await pb + .collection('organizations') + .getFirstListItem(`clerkId=${clerkId}`) + return organization as Organization + } catch (error) { + // If organization not found, return null instead of throwing + if (error instanceof Error && error.message.includes('404')) { + return null + } + console.error('Error fetching organization by clerk ID:', error) + return null + } +} diff --git a/src/app/actions/services/pocketbase/organization/membership.ts b/src/app/actions/services/pocketbase/organization/membership.ts new file mode 100644 index 0000000..f488c6d --- /dev/null +++ b/src/app/actions/services/pocketbase/organization/membership.ts @@ -0,0 +1,185 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { User } from '@/types/types_pocketbase' + +/** + * Membership management functions for organizations + */ + +/** + * Internal: Add user to organization without security checks + * @param userId User ID + * @param organizationId Organization ID + */ +export async function _addUserToOrganization( + userId: string, + organizationId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Get the user + const user = await pb.collection('users').getOne(userId, { + expand: 'organizations', + }) + + // Get current organizations + let currentOrgs = user.organizations || [] + if (typeof currentOrgs === 'string') { + currentOrgs = [currentOrgs] + } + + // Check if user is already in organization + if (!currentOrgs.includes(organizationId)) { + // Add organization to user's organizations list + currentOrgs.push(organizationId) + } + + // Update user with new organizations list + return await pb.collection('users').update(userId, { + organizations: currentOrgs, + }) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._addUserToOrganization' + ) + } +} + +/** + * Internal: Remove user from organization without security checks + * @param userId User ID + * @param organizationId Organization ID + */ +export async function _removeUserFromOrganization( + userId: string, + organizationId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Get the user + const user = await pb.collection('users').getOne(userId, { + expand: 'organizations', + }) + + // Get current organizations + let currentOrgs = user.organizations || [] + if (typeof currentOrgs === 'string') { + currentOrgs = [currentOrgs] + } + + // Remove organization from user's organizations list + const updatedOrgs = currentOrgs.filter(orgId => orgId !== organizationId) + + // Update user with new organizations list + return await pb.collection('users').update(userId, { + organizations: updatedOrgs, + }) + } catch (error) { + return handlePocketBaseError( + error, + 'OrganizationService._removeUserFromOrganization' + ) + } +} + +/** + * Add a user to an organization + */ +export async function addUserToOrganization( + userId: string, + organizationId: string, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for member management + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + } + + return await _addUserToOrganization(userId, organizationId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.addUserToOrganization' + ) + } +} + +/** + * Remove a user from an organization + */ +export async function removeUserFromOrganization( + userId: string, + organizationId: string, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for member management + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + } + + return await _removeUserFromOrganization(userId, organizationId) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.removeUserFromOrganization' + ) + } +} + +/** + * Get all users in an organization + */ +export async function getOrganizationUsers( + organizationId: string +): Promise { + try { + // Security check - validates user has access to this organization + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with this organization in their organizations list + const result = await pb.collection('users').getList(1, 100, { + filter: `organizations ~ "${organizationId}"`, + }) + + return result.items + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'OrganizationService.getOrganizationUsers' + ) + } +} diff --git a/src/app/actions/services/pocketbase/organization/security.ts b/src/app/actions/services/pocketbase/organization/security.ts new file mode 100644 index 0000000..8e14ed3 --- /dev/null +++ b/src/app/actions/services/pocketbase/organization/security.ts @@ -0,0 +1,43 @@ +'use server' + +import { handlePocketBaseError } from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' + +import { getUserOrganizations } from './core' + +/** + * Organization-specific security functions + */ + +/** + * Check if current user is organization admin for a specific organization + */ +export async function isCurrentUserOrgAdmin( + organizationId: string +): Promise { + try { + // Get current user + const user = await validateCurrentUser() + + // Check if user has admin role + const isAdmin = user.isAdmin || user.role === 'admin' + + if (!isAdmin) { + return false + } + + // Verify they belong to this organization + const userOrgs = await getUserOrganizations() + const belongsToOrg = userOrgs.some(org => org.id === organizationId) + + return isAdmin && belongsToOrg + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return false + } +} diff --git a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts new file mode 100644 index 0000000..ed8ee64 --- /dev/null +++ b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts @@ -0,0 +1,310 @@ +'use server' + +import { userService } from '@/app/actions/services/pocketbase/userService' +import { + ClerkOrganizationWebhookData, + ClerkMembershipWebhookData, + WebhookProcessingResult, +} from '@/types/webhooks' + +import { + createOrganization, + updateOrganization, + deleteOrganization, +} from './core' +import { getByClerkId } from './internal' +import { addUserToOrganization, removeUserFromOrganization } from './membership' + +/** + * Webhook handlers for organization events from Clerk + */ + +/** + * Handles a webhook event for organization creation + * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookCreated( + data: ClerkOrganizationWebhookData, + elevated = true +): Promise { + try { + // Check if already exists + const existing = await getByClerkId(data.id) + if (existing) { + return { + message: `Organization ${data.id} already exists`, + success: true, + } + } + + // Create new organization + await createOrganization( + { + clerkId: data.id, + name: data.name, + settings: { + imageUrl: data.image_url || null, + logoUrl: data.logo_url || null, + slug: data.slug || null, + }, + }, + elevated + ) + + return { + message: `Created organization ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process organization creation webhook:', error) + return { + message: `Failed to process organization creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for organization update + * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookUpdated( + data: ClerkOrganizationWebhookData, + elevated = true +): Promise { + try { + // Find existing organization + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `Organization ${data.id} not found`, + success: false, + } + } + + // Update organization + await updateOrganization( + existing.id, + { + name: data.name, + settings: { + ...existing.settings, + imageUrl: data.image_url || existing.settings?.imageUrl || null, + logoUrl: data.logo_url || existing.settings?.logoUrl || null, + slug: data.slug || existing.settings?.slug || null, + }, + }, + elevated + ) + + return { + message: `Updated organization ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process organization update webhook:', error) + return { + message: `Failed to process organization update: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for organization deletion + * @param {ClerkOrganizationWebhookData} data - Organization deletion data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookDeleted( + data: ClerkOrganizationWebhookData, + elevated = true +): Promise { + try { + // Find existing organization + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `Organization ${data.id} already deleted or not found`, + success: true, + } + } + + // Delete organization + await deleteOrganization(existing.id, elevated) + + return { + message: `Deleted organization ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process organization deletion webhook:', error) + return { + message: `Failed to process organization deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles membership creation from webhook + * @param {ClerkMembershipWebhookData} data - Membership data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleMembershipWebhookCreated( + data: ClerkMembershipWebhookData, + elevated = true +): Promise { + try { + // Get organization and user + const organization = await getByClerkId(data.organization.id) + if (!organization) { + return { + message: `Organization with Clerk ID ${data.organization.id} not found`, + success: false, + } + } + + const user = await userService.getByClerkId(data.public_user_data.user_id) + if (!user) { + return { + message: `User with Clerk ID ${data.public_user_data.user_id} not found`, + success: false, + } + } + + // Add user to organization + await addUserToOrganization(user.id, organization.id, elevated) + + // Update user role if needed + if (data.role === 'admin') { + await userService.updateUser( + user.id, + { + role: 'admin', + }, + elevated + ) + } + + return { + message: `Added user ${user.id} to organization ${organization.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process membership creation webhook:', error) + return { + message: `Failed to process membership creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles membership update from webhook + * @param {ClerkMembershipWebhookData} data - Membership data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleMembershipWebhookUpdated( + data: ClerkMembershipWebhookData, + elevated = true +): Promise { + try { + // Get organization and user + const organization = await getByClerkId(data.organization.id) + if (!organization) { + return { + message: `Organization with Clerk ID ${data.organization.id} not found`, + success: false, + } + } + + const user = await userService.getByClerkId(data.public_user_data.user_id) + if (!user) { + return { + message: `User with Clerk ID ${data.public_user_data.user_id} not found`, + success: false, + } + } + + // Update user role if needed + if (data.role === 'admin') { + await userService.updateUser( + user.id, + { + role: 'admin', + }, + elevated + ) + } else if (data.role === 'basic_member') { + await userService.updateUser( + user.id, + { + role: 'member', + }, + elevated + ) + } + + return { + message: `Updated user ${user.id} role in organization ${organization.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process membership update webhook:', error) + return { + message: `Failed to process membership update: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles membership deletion from webhook + * @param {ClerkMembershipWebhookData} data - Membership data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleMembershipWebhookDeleted( + data: ClerkMembershipWebhookData, + elevated = true +): Promise { + try { + // Get organization and user + const organization = await getByClerkId(data.organization.id) + if (!organization) { + return { + message: `Organization with Clerk ID ${data.organization.id} not found`, + success: true, + } + } + + const user = await userService.getByClerkId(data.public_user_data.user_id) + if (!user) { + return { + message: `User with Clerk ID ${data.public_user_data.user_id} not found`, + success: true, + } + } + + // Remove user from organization + await removeUserFromOrganization(user.id, organization.id, elevated) + + return { + message: `Removed user ${user.id} from organization ${organization.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process membership deletion webhook:', error) + return { + message: `Failed to process membership deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts deleted file mode 100644 index d415958..0000000 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ /dev/null @@ -1,940 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, - RecordData, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - validateOrganizationAccess, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { userService } from '@/app/actions/services/pocketbase/userService' -import { - Organization, - User, - ListOptions, - ListResult, -} from '@/types/types_pocketbase' -import { - ClerkOrganizationWebhookData, - ClerkMembershipWebhookData, - WebhookProcessingResult, -} from '@/types/webhooks' - -// ============================ -// Internal methods (no security checks) -// ============================ - -/** - * Internal: Create organization without security checks - * @param data Organization data - */ -async function _createOrganization( - data: Partial -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('organizations').create(data) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._createOrganization' - ) - } -} - -/** - * Internal: Update organization without security checks - * @param id Organization ID - * @param data Updated organization data - */ -async function _updateOrganization( - id: string, - data: Partial -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('organizations').update(id, data) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._updateOrganization' - ) - } -} - -/** - * Internal: Delete organization without security checks - * @param id Organization ID - */ -async function _deleteOrganization(id: string): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('organizations').delete(id) - return true - } catch (error) { - handlePocketBaseError(error, 'OrganizationService._deleteOrganization') - return false - } -} - -/** - * Internal: Add user to organization without security checks - * @param userId User ID - * @param organizationId Organization ID - */ -async function _addUserToOrganization( - userId: string, - organizationId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Get the user - const user = await pb.collection('users').getOne(userId, { - expand: 'organizations', - }) - - // Get current organizations - let currentOrgs = user.organizations || [] - if (typeof currentOrgs === 'string') { - currentOrgs = [currentOrgs] - } - - // Check if user is already in organization - if (!currentOrgs.includes(organizationId)) { - // Add organization to user's organizations list - currentOrgs.push(organizationId) - } - - // Update user with new organizations list - return await pb.collection('users').update(userId, { - organizations: currentOrgs, - }) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._addUserToOrganization' - ) - } -} - -/** - * Internal: Remove user from organization without security checks - * @param userId User ID - * @param organizationId Organization ID - */ -async function _removeUserFromOrganization( - userId: string, - organizationId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Get the user - const user = await pb.collection('users').getOne(userId, { - expand: 'organizations', - }) - - // Get current organizations - let currentOrgs = user.organizations || [] - if (typeof currentOrgs === 'string') { - currentOrgs = [currentOrgs] - } - - // Remove organization from user's organizations list - const updatedOrgs = currentOrgs.filter(orgId => orgId !== organizationId) - - // Update user with new organizations list - return await pb.collection('users').update(userId, { - organizations: updatedOrgs, - }) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._removeUserFromOrganization' - ) - } -} - -// ============================ -// Public API methods (with security) -// ============================ - -/** - * Get a single organization by ID with security validation - */ -export async function getOrganization(id: string): Promise { - try { - // Security check - validates user has access to this organization - await validateOrganizationAccess(id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('organizations').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'OrganizationService.getOrganization') - } -} - -/** - * Get an organization by Clerk ID with security validation - * This is primarily used during authentication - */ -export async function getOrganizationByClerkId( - clerkId: string -): Promise { - try { - // Validate current user is authenticated - const user = await validateCurrentUser() - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const organization = await pb - .collection('organizations') - .getFirstListItem(`clerkId="${clerkId}"`) - - // Check if user has access to this organization - // Get the user with expanded organizations - const userWithOrgs = await pb.collection('users').getOne(user.id, { - expand: 'organizations', - }) - - // Check if the user has access to this organization - const hasAccess = - userWithOrgs.organizations && - userWithOrgs.organizations.some( - (orgId: string) => orgId === organization.id - ) - - if (!hasAccess) { - throw new SecurityError('User does not belong to this organization') - } - - return organization - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationByClerkId' - ) - } -} - -/** - * Get organizations list for the current user - */ -export async function getUserOrganizations(): Promise { - try { - const user = await validateCurrentUser() - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fetch the user with expanded organizations - const userWithOrgs = await pb.collection('users').getOne(user.id, { - expand: 'organizations', - }) - - if (!userWithOrgs.expand?.organizations) { - return [] - } - - return userWithOrgs.expand.organizations - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getUserOrganizations' - ) - } -} - -/** - * Get all users in an organization - */ -export async function getOrganizationUsers( - organizationId: string -): Promise { - try { - // Security check - validates user has access to this organization - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with this organization in their organizations list - const result = await pb.collection('users').getList(1, 100, { - filter: `organizations ~ "${organizationId}"`, - }) - - return result.items - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationUsers' - ) - } -} - -/** - * Get organizations list with pagination - * This should only be accessible to super-admins in regular operation - */ -export async function getOrganizationsList( - options: ListOptions = {}, - elevated = false -): Promise> { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError( - 'This operation is restricted to super administrators' - ) - } - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb - .collection('organizations') - .getList(options.page || 1, options.perPage || 50, { - filter: options.filter, - sort: options.sort, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationsList' - ) - } -} - -/** - * Create a new organization - supports both regular and elevated access - */ -export async function createOrganization( - data: Partial, - elevated = false -): Promise { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError( - 'This operation is restricted to super administrators' - ) - } - } - - // For elevated access (webhooks), we bypass additional security checks - return await _createOrganization(data) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.createOrganization' - ) - } -} - -/** - * Update an organization with security validation - */ -export async function updateOrganization( - id: string, - data: Partial, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for organization updates - await validateOrganizationAccess(id, PermissionLevel.ADMIN) - - // Sanitize sensitive fields for regular users - const sanitizedData = { ...data } - - // Never allow changing the clerkId - that's a special binding - delete sanitizedData.clerkId - - // Don't allow changing Stripe-related fields directly - delete sanitizedData.stripeCustomerId - delete sanitizedData.subscriptionId - delete sanitizedData.subscriptionStatus - delete sanitizedData.priceId - - return await _updateOrganization(id, sanitizedData) - } - - // For elevated access, use the data as provided - return await _updateOrganization(id, data) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.updateOrganization' - ) - } -} - -/** - * Delete an organization - supports both regular and elevated access - */ -export async function deleteOrganization( - id: string, - elevated = false -): Promise { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError( - 'This operation is restricted to super administrators' - ) - } - } - - // For elevated access (webhooks), we bypass additional security checks - return await _deleteOrganization(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - handlePocketBaseError(error, 'OrganizationService.deleteOrganization') - return false - } -} - -/** - * Update organization subscription details - * This should only be called from Stripe webhooks, not directly by users - */ -export async function updateSubscription( - id: string, - subscriptionData: { - stripeCustomerId?: string - subscriptionId?: string - subscriptionStatus?: string - priceId?: string - }, - elevated = false -): Promise { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError('This operation is restricted') - } - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Verify the organization exists - const organization = await pb.collection('organizations').getOne(id) - if (!organization) { - throw new Error('Organization not found') - } - - return await pb.collection('organizations').update(id, subscriptionData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.updateSubscription' - ) - } -} - -/** - * Get current organization settings for the authenticated user - * If the user belongs to multiple organizations, takes the first active one - */ -export async function getCurrentOrganizationSettings(): Promise { - try { - // Get all organizations for the current user - const userOrganizations = await getUserOrganizations() - - if (!userOrganizations.length) { - throw new SecurityError('User does not belong to any organization') - } - - // For simplicity, we're returning the first organization - const firstOrgId = userOrganizations[0].id - - // Fetch full organization details with validated access - return await getOrganization(firstOrgId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getCurrentOrganizationSettings' - ) - } -} - -/** - * Check if current user is organization admin for a specific organization - */ -export async function isCurrentUserOrgAdmin( - organizationId: string -): Promise { - try { - // Get current user - const user = await validateCurrentUser() - - // Check if user has admin role - const isAdmin = user.isAdmin || user.role === 'admin' - - if (!isAdmin) { - return false - } - - // Verify they belong to this organization - const userOrgs = await getUserOrganizations() - const belongsToOrg = userOrgs.some(org => org.id === organizationId) - - return isAdmin && belongsToOrg - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return false - } -} - -/** - * Add a user to an organization - */ -export async function addUserToOrganization( - userId: string, - organizationId: string, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for member management - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - } - - return await _addUserToOrganization(userId, organizationId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.addUserToOrganization' - ) - } -} - -/** - * Remove a user from an organization - */ -export async function removeUserFromOrganization( - userId: string, - organizationId: string, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for member management - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - } - - return await _removeUserFromOrganization(userId, organizationId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.removeUserFromOrganization' - ) - } -} - -/** - * Gets an organization by Clerk ID - * @param {string} clerkId - Clerk organization ID - * @returns {Promise} Organization record or null if not found - */ -export async function getByClerkId( - clerkId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const organization = await pb - .collection('organizations') - .getFirstListItem(`clerkId=${clerkId}`) - return organization as Organization - } catch (error) { - // If organization not found, return null instead of throwing - if (error instanceof Error && error.message.includes('404')) { - return null - } - console.error('Error fetching organization by clerk ID:', error) - return null - } -} - -// ============================ -// Webhook handler methods -// ============================ - -/** - * Handles a webhook event for organization creation - * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookCreated( - data: ClerkOrganizationWebhookData, - elevated = true -): Promise { - try { - // Check if already exists - const existing = await getByClerkId(data.id) - if (existing) { - return { - message: `Organization ${data.id} already exists`, - success: true, - } - } - - // Create new organization - await createOrganization( - { - clerkId: data.id, - name: data.name, - settings: { - imageUrl: data.image_url || null, - logoUrl: data.logo_url || null, - slug: data.slug || null, - }, - }, - elevated - ) - - return { - message: `Created organization ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process organization creation webhook:', error) - return { - message: `Failed to process organization creation: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for organization update - * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookUpdated( - data: ClerkOrganizationWebhookData, - elevated = true -): Promise { - try { - // Find existing organization - const existing = await getByClerkId(data.id) - if (!existing) { - return { - message: `Organization ${data.id} not found`, - success: false, - } - } - - // Update organization - await updateOrganization( - existing.id, - { - name: data.name, - settings: { - ...existing.settings, - imageUrl: data.image_url || existing.settings?.imageUrl || null, - logoUrl: data.logo_url || existing.settings?.logoUrl || null, - slug: data.slug || existing.settings?.slug || null, - }, - }, - elevated - ) - - return { - message: `Updated organization ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process organization update webhook:', error) - return { - message: `Failed to process organization update: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for organization deletion - * @param {ClerkOrganizationWebhookData} data - Organization deletion data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookDeleted( - data: ClerkOrganizationWebhookData, - elevated = true -): Promise { - try { - // Find existing organization - const existing = await getByClerkId(data.id) - if (!existing) { - return { - message: `Organization ${data.id} already deleted or not found`, - success: true, - } - } - - // Delete organization - await deleteOrganization(existing.id, elevated) - - return { - message: `Deleted organization ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process organization deletion webhook:', error) - return { - message: `Failed to process organization deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles membership creation from webhook - * @param {ClerkMembershipWebhookData} data - Membership data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleMembershipWebhookCreated( - data: ClerkMembershipWebhookData, - elevated = true -): Promise { - try { - // Get organization and user - const organization = await getByClerkId(data.organization.id) - if (!organization) { - return { - message: `Organization with Clerk ID ${data.organization.id} not found`, - success: false, - } - } - - const user = await userService.getByClerkId(data.public_user_data.user_id) - if (!user) { - return { - message: `User with Clerk ID ${data.public_user_data.user_id} not found`, - success: false, - } - } - - // Add user to organization - await addUserToOrganization(user.id, organization.id, elevated) - - // Update user role if needed - if (data.role === 'admin') { - await userService.updateUser( - user.id, - { - role: 'admin', - }, - elevated - ) - } - - return { - message: `Added user ${user.id} to organization ${organization.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process membership creation webhook:', error) - return { - message: `Failed to process membership creation: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles membership update from webhook - * @param {ClerkMembershipWebhookData} data - Membership data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleMembershipWebhookUpdated( - data: ClerkMembershipWebhookData, - elevated = true -): Promise { - try { - // Get organization and user - const organization = await getByClerkId(data.organization.id) - if (!organization) { - return { - message: `Organization with Clerk ID ${data.organization.id} not found`, - success: false, - } - } - - const user = await userService.getByClerkId(data.public_user_data.user_id) - if (!user) { - return { - message: `User with Clerk ID ${data.public_user_data.user_id} not found`, - success: false, - } - } - - // Update user role if needed - if (data.role === 'admin') { - await userService.updateUser( - user.id, - { - role: 'admin', - }, - elevated - ) - } else if (data.role === 'basic_member') { - await userService.updateUser( - user.id, - { - role: 'member', - }, - elevated - ) - } - - return { - message: `Updated user ${user.id} role in organization ${organization.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process membership update webhook:', error) - return { - message: `Failed to process membership update: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles membership deletion from webhook - * @param {ClerkMembershipWebhookData} data - Membership data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleMembershipWebhookDeleted( - data: ClerkMembershipWebhookData, - elevated = true -): Promise { - try { - // Get organization and user - const organization = await getByClerkId(data.organization.id) - if (!organization) { - return { - message: `Organization with Clerk ID ${data.organization.id} not found`, - success: true, - } - } - - const user = await userService.getByClerkId(data.public_user_data.user_id) - if (!user) { - return { - message: `User with Clerk ID ${data.public_user_data.user_id} not found`, - success: true, - } - } - - // Remove user from organization - await removeUserFromOrganization(user.id, organization.id, elevated) - - return { - message: `Removed user ${user.id} from organization ${organization.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process membership deletion webhook:', error) - return { - message: `Failed to process membership deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} From dfae7fb771682f24f327143627e1a0f1c61300d7 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 18:06:16 +0200 Subject: [PATCH 26/73] feat(organization): streamline imports and structure - Update import paths for organization-related functions - Remove redundant internal imports in core file - Enhance organization security functions with direct access methods - Clean up webhook handlers by consolidating imports --- .../services/pocketbase/organization/core.ts | 11 +++++------ .../services/pocketbase/organization/index.ts | 10 +++++----- .../pocketbase/organization/internal.ts | 2 +- .../pocketbase/organization/security.ts | 4 +--- .../organization/webhook-handlers.ts | 19 ++++++++++--------- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/app/actions/services/pocketbase/organization/core.ts b/src/app/actions/services/pocketbase/organization/core.ts index 522e952..d4d7c6b 100644 --- a/src/app/actions/services/pocketbase/organization/core.ts +++ b/src/app/actions/services/pocketbase/organization/core.ts @@ -4,6 +4,11 @@ import { getPocketBase, handlePocketBaseError, } from '@/app/actions/services/pocketbase/baseService' +import { + _createOrganization, + _updateOrganization, + _deleteOrganization, +} from '@/app/actions/services/pocketbase/organization/internal' import { validateCurrentUser, validateOrganizationAccess, @@ -12,12 +17,6 @@ import { } from '@/app/actions/services/pocketbase/securityUtils' import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' -import { - _createOrganization, - _updateOrganization, - _deleteOrganization, -} from './internal' - /** * Core organization operations with security validations */ diff --git a/src/app/actions/services/pocketbase/organization/index.ts b/src/app/actions/services/pocketbase/organization/index.ts index 98d735b..d3a6cd8 100644 --- a/src/app/actions/services/pocketbase/organization/index.ts +++ b/src/app/actions/services/pocketbase/organization/index.ts @@ -13,17 +13,17 @@ export { deleteOrganization, updateSubscription, getCurrentOrganizationSettings, -} from './core' +} from '@/app/actions/services/pocketbase/organization/core' // Membership functions export { addUserToOrganization, removeUserFromOrganization, getOrganizationUsers, -} from './membership' +} from '@/app/actions/services/pocketbase/organization/membership' // Security utilities -export { isCurrentUserOrgAdmin } from './security' +export { isCurrentUserOrgAdmin } from '@/app/actions/services/pocketbase/organization/security' // Webhook handlers export { @@ -33,7 +33,7 @@ export { handleMembershipWebhookCreated, handleMembershipWebhookUpdated, handleMembershipWebhookDeleted, -} from './webhook-handlers' +} from '@/app/actions/services/pocketbase/organization/webhook-handlers' // Internal functions for direct access when needed -export { getByClerkId } from './internal' +export { getByClerkId } from '@/app/actions/services/pocketbase/organization/internal' diff --git a/src/app/actions/services/pocketbase/organization/internal.ts b/src/app/actions/services/pocketbase/organization/internal.ts index d4ab101..633ed47 100644 --- a/src/app/actions/services/pocketbase/organization/internal.ts +++ b/src/app/actions/services/pocketbase/organization/internal.ts @@ -4,7 +4,7 @@ import { getPocketBase, handlePocketBaseError, } from '@/app/actions/services/pocketbase/baseService' -import { Organization, User } from '@/types/types_pocketbase' +import { Organization } from '@/types/types_pocketbase' /** * Internal methods for organization management diff --git a/src/app/actions/services/pocketbase/organization/security.ts b/src/app/actions/services/pocketbase/organization/security.ts index 8e14ed3..adf516b 100644 --- a/src/app/actions/services/pocketbase/organization/security.ts +++ b/src/app/actions/services/pocketbase/organization/security.ts @@ -1,13 +1,11 @@ 'use server' -import { handlePocketBaseError } from '@/app/actions/services/pocketbase/baseService' +import { getUserOrganizations } from '@/app/actions/services/pocketbase/organization/core' import { validateCurrentUser, SecurityError, } from '@/app/actions/services/pocketbase/securityUtils' -import { getUserOrganizations } from './core' - /** * Organization-specific security functions */ diff --git a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts index ed8ee64..477b898 100644 --- a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts +++ b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts @@ -1,20 +1,21 @@ 'use server' -import { userService } from '@/app/actions/services/pocketbase/userService' +import { + createOrganization, + updateOrganization, + deleteOrganization, +} from '@/app/actions/services/pocketbase/organization/core' +import { getByClerkId } from '@/app/actions/services/pocketbase/organization/internal' +import { + addUserToOrganization, + removeUserFromOrganization, +} from '@/app/actions/services/pocketbase/organization/membership' import { ClerkOrganizationWebhookData, ClerkMembershipWebhookData, WebhookProcessingResult, } from '@/types/webhooks' -import { - createOrganization, - updateOrganization, - deleteOrganization, -} from './core' -import { getByClerkId } from './internal' -import { addUserToOrganization, removeUserFromOrganization } from './membership' - /** * Webhook handlers for organization events from Clerk */ From 1d5344190bc860f2940c8407a802cf53e7834121 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 18:37:37 +0200 Subject: [PATCH 27/73] feat(user): enhance user management and authentication - Rename User model to AppUser for clarity - Add lastLogin field to track user activity - Implement updateUserLastLogin function for login tracking - Refactor user service methods for better organization and security checks - Introduce webhook handlers for user creation, updates, and deletions from Clerk - Improve error handling in PocketBase interactions --- .cursor/rules/rules-diagram-mermaid.mdc | 13 +- .../services/pocketbase/baseService.ts | 6 +- .../actions/services/pocketbase/user/auth.ts | 45 +++ .../actions/services/pocketbase/user/core.ts | 214 ++++++++++ .../actions/services/pocketbase/user/index.ts | 34 ++ .../services/pocketbase/user/internal.ts | 96 +++++ .../services/pocketbase/user/search.ts | 146 +++++++ .../pocketbase/user/webhook-handlers.ts | 155 ++++++++ .../services/pocketbase/userService.ts | 365 ------------------ src/app/api/webhook/clerk/user/route.ts | 2 - src/types/types_pocketbase.ts | 4 +- 11 files changed, 703 insertions(+), 377 deletions(-) create mode 100644 src/app/actions/services/pocketbase/user/auth.ts create mode 100644 src/app/actions/services/pocketbase/user/core.ts create mode 100644 src/app/actions/services/pocketbase/user/index.ts create mode 100644 src/app/actions/services/pocketbase/user/internal.ts create mode 100644 src/app/actions/services/pocketbase/user/search.ts create mode 100644 src/app/actions/services/pocketbase/user/webhook-handlers.ts delete mode 100644 src/app/actions/services/pocketbase/userService.ts diff --git a/.cursor/rules/rules-diagram-mermaid.mdc b/.cursor/rules/rules-diagram-mermaid.mdc index 59af135..bb11bf7 100644 --- a/.cursor/rules/rules-diagram-mermaid.mdc +++ b/.cursor/rules/rules-diagram-mermaid.mdc @@ -20,7 +20,7 @@ erDiagram date updated } - User { + AppUser { string id PK string name string email @@ -30,7 +30,7 @@ erDiagram boolean verified boolean emailVisibility string clerkId - file avatar + date lastLogin date created date updated } @@ -83,15 +83,16 @@ erDiagram date updated } - Organization ||--o{ User : "has" + Organization ||--o{ UserApp : "has" Organization ||--o{ Equipment : "owns" Organization ||--o{ Project : "manages" Organization ||--o{ Assignment : "oversees" Organization ||--o{ ActivityLog : "tracks" - User }o--o{ Organization : "belongs to" - User }o--o{ Assignment : "is assigned to" - User }o--o{ ActivityLog : "generates" + UserApp }o--o{ Organization : "belongs to" + UserApp }o--o{ Assignment : "is assigned to" + UserApp }o--o{ ActivityLog : "generates" + UserApp }o--|| Image : "have" Equipment }o--o{ Assignment : "is assigned via" Equipment }o--o{ Equipment : "parent/child" diff --git a/src/app/actions/services/pocketbase/baseService.ts b/src/app/actions/services/pocketbase/baseService.ts index f0233e7..ddd34f2 100644 --- a/src/app/actions/services/pocketbase/baseService.ts +++ b/src/app/actions/services/pocketbase/baseService.ts @@ -17,8 +17,10 @@ export const getPocketBase = async (): Promise => { } // Get credentials from environment variables - const token = process.env.PB_USER_TOKEN - const url = process.env.PB_SERVER_URL + // PB_TOKEN_API_ADMIN + // PB_API_URL + const token = process.env.PB_TOKEN_API_ADMIN + const url = process.env.PB_API_URL if (!token || !url) { console.error('Missing PocketBase credentials in environment variables') diff --git a/src/app/actions/services/pocketbase/user/auth.ts b/src/app/actions/services/pocketbase/user/auth.ts new file mode 100644 index 0000000..b7e616e --- /dev/null +++ b/src/app/actions/services/pocketbase/user/auth.ts @@ -0,0 +1,45 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { User } from '@/types/types_pocketbase' + +/** + * Authentication-related functions for users + */ + +/** + * Update user's last login time + * This is typically called during authentication flows + */ +export async function updateUserLastLogin(id: string): Promise { + try { + // Since this is called during authentication, + // we'll just verify the user exists rather than permissions + const user = await validateCurrentUser(id) + + if (!user) { + throw new SecurityError('User not found') + } + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').update(id, { + lastLogin: new Date().toISOString(), + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.updateUserLastLogin') + } +} diff --git a/src/app/actions/services/pocketbase/user/core.ts b/src/app/actions/services/pocketbase/user/core.ts new file mode 100644 index 0000000..c920503 --- /dev/null +++ b/src/app/actions/services/pocketbase/user/core.ts @@ -0,0 +1,214 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + validateResourceAccess, + SecurityError, + ResourceType, + PermissionLevel, +} from '@/app/actions/services/pocketbase/securityUtils' +import { + _updateUser, + _createUser, + _deleteUser, +} from '@/app/actions/services/pocketbase/user/internal' +import { User } from '@/types/types_pocketbase' + +/** + * Core user operations with security validations + */ + +/** + * Get a single user by ID with security validation + */ +export async function getUser(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'UserService.getUser') + } +} + +/** + * Get current authenticated user profile + */ +export async function getCurrentUser(): Promise { + try { + // This function automatically validates the current user + return await validateCurrentUser() + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getCurrentUser') + } +} + +/** + * Get a user by Clerk ID - typically used during authentication + */ +export async function getUserByClerkId(clerkId: string): Promise { + // This is primarily used during authentication flows where + // standard security checks aren't possible yet. + // However, requests should still come from server-side code only. + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + try { + return await pb.collection('users').getFirstListItem(`clerkId="${clerkId}"`) + } catch (error) { + return handlePocketBaseError(error, 'UserService.getUserByClerkId') + } +} + +/** + * Create a new user with security checks + * This is typically controlled access for admins only + */ +export async function createUser( + organizationId: string, + data: Pick< + Partial, + | 'name' + | 'email' + | 'emailVisibility' + | 'verified' + | 'avatar' + | 'phone' + | 'role' + | 'isAdmin' + | 'canLogin' + | 'clerkId' + >, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission to create users + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + } + + // Ensure organization ID is set correctly with the proper field name + return await _createUser({ + ...data, + organizations: [organizationId], // Force the correct organization ID using the relation field + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.createUser') + } +} + +/** + * Update a user with security checks + */ +export async function updateUser( + id: string, + data: Pick< + Partial, + | 'name' + | 'email' + | 'emailVisibility' + | 'verified' + | 'avatar' + | 'phone' + | 'role' + | 'isAdmin' + | 'canLogin' + | 'lastLogin' + | 'clerkId' + >, + elevated = false +): Promise { + try { + if (!elevated) { + // Get current authenticated user + const currentUser = await validateCurrentUser() + + // Different permission checks based on who is being updated + if (id !== currentUser.id) { + // Updating someone else requires ADMIN permission + await validateResourceAccess( + ResourceType.USER, + id, + PermissionLevel.ADMIN + ) + } else { + // Users can update their own basic info + // But for role changes, they'd still need admin rights + if (data.role || data.isAdmin !== undefined) { + // If trying to change role or admin status, require admin permission + // Get the user's organization ID - handling possible multiple organizations + const userOrgs = currentUser.expand?.organizations + + if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { + throw new SecurityError('User does not belong to any organization') + } + + // Use the first organization for permission check + const primaryOrgId = userOrgs[0].id + await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) + } + } + } + + // Security: never allow changing certain fields + const sanitizedData = { ...data } + // Don't allow org changes directly - use proper organization management functions + delete (sanitizedData as Record).organizations + + if (!elevated) { + // Only elevated calls (webhooks) can change the clerkId + delete sanitizedData.clerkId + } + + return await _updateUser(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.updateUser') + } +} + +/** + * Delete a user with admin permission check + */ +export async function deleteUser( + id: string, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for user deletion + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) + } + + return await _deleteUser(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.deleteUser') + } +} diff --git a/src/app/actions/services/pocketbase/user/index.ts b/src/app/actions/services/pocketbase/user/index.ts new file mode 100644 index 0000000..98a6ddf --- /dev/null +++ b/src/app/actions/services/pocketbase/user/index.ts @@ -0,0 +1,34 @@ +/** + * User service - public exports + */ + +// Core operations +export { + getUser, + getCurrentUser, + getUserByClerkId, + createUser, + updateUser, + deleteUser, +} from '@/app/actions/services/pocketbase/user/core' + +// Search functions +export { + getUsersList, + getUsersByOrganization, + getUserCount, + searchUsers, +} from '@/app/actions/services/pocketbase/user/search' + +// Authentication functions +export { updateUserLastLogin } from '@/app/actions/services/pocketbase/user/auth' + +// Webhook handlers +export { + handleWebhookCreated, + handleWebhookUpdated, + handleWebhookDeleted, +} from '@/app/actions/services/pocketbase/user/webhook-handlers' + +// Internal functions that might be used by other services +export { getByClerkId } from '@/app/actions/services/pocketbase/user/internal' diff --git a/src/app/actions/services/pocketbase/user/internal.ts b/src/app/actions/services/pocketbase/user/internal.ts new file mode 100644 index 0000000..a47de26 --- /dev/null +++ b/src/app/actions/services/pocketbase/user/internal.ts @@ -0,0 +1,96 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { User } from '@/types/types_pocketbase' + +/** + * Internal methods for user management + * These methods have no security checks and should only be called + * from secured public API methods + */ + +/** + * Internal: Update user without security checks + * @param id User ID + * @param data User data + */ +export async function _updateUser( + id: string, + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').update(id, data) + } catch (error) { + return handlePocketBaseError(error, 'UserService._updateUser') + } +} + +/** + * Internal: Create user without security checks + * @param data User data + */ +export async function _createUser(data: Partial): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('users').create(data) + } catch (error) { + return handlePocketBaseError(error, 'UserService._createUser') + } +} + +/** + * Internal: Delete user without security checks + * @param id User ID + */ +export async function _deleteUser(id: string): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('users').delete(id) + return true + } catch (error) { + handlePocketBaseError(error, 'UserService._deleteUser') + return false + } +} + +/** + * Get a user by Clerk ID + * @param clerkId Clerk user ID + * @returns User record or null if not found + */ +export async function getByClerkId(clerkId: string): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const user = await pb + .collection('users') + .getFirstListItem(`clerkId="${clerkId}"`) + return user + } catch (error) { + // If user not found, return null instead of throwing + if (error instanceof Error && error.message.includes('404')) { + return null + } + console.error('Error fetching user by clerk ID:', error) + return null + } +} diff --git a/src/app/actions/services/pocketbase/user/search.ts b/src/app/actions/services/pocketbase/user/search.ts new file mode 100644 index 0000000..1426f95 --- /dev/null +++ b/src/app/actions/services/pocketbase/user/search.ts @@ -0,0 +1,146 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + createOrganizationFilter, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { ListOptions, ListResult, User } from '@/types/types_pocketbase' + +/** + * Search and listing functions for users + */ + +/** + * Get users list with pagination and security checks + */ +export async function getUsersList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check - needs at least READ permission + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = createOrganizationFilter(organizationId, additionalFilter) + + return await pb.collection('users').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getUsersList') + } +} + +/** + * Get all users for an organization with security checks + */ +export async function getUsersByOrganization( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with this organization in their organizations relation + return await pb.collection('users').getFullList({ + filter: `organizations ~ "${organizationId}"`, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getUsersByOrganization') + } +} + +/** + * Get the count of users in an organization + */ +export async function getUserCount(organizationId: string): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with this organization in their organizations relation + const result = await pb.collection('users').getList(1, 1, { + filter: `organizations ~ "${organizationId}"`, + skipTotal: false, + }) + + return result.totalItems + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.getUserCount') + } +} + +/** + * Search for users in the organization + */ +export async function searchUsers( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with search conditions + return await pb.collection('users').getFullList({ + filter: pb.filter( + 'organizations ~ {:orgId} && (name ~ {:query} || email ~ {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'UserService.searchUsers') + } +} diff --git a/src/app/actions/services/pocketbase/user/webhook-handlers.ts b/src/app/actions/services/pocketbase/user/webhook-handlers.ts new file mode 100644 index 0000000..24f0f43 --- /dev/null +++ b/src/app/actions/services/pocketbase/user/webhook-handlers.ts @@ -0,0 +1,155 @@ +'use server' + +import { + createUser, + updateUser, + deleteUser, +} from '@/app/actions/services/pocketbase/user/core' +import { getByClerkId } from '@/app/actions/services/pocketbase/user/internal' +import { ClerkUserWebhookData, WebhookProcessingResult } from '@/types/webhooks' + +/** + * Webhook handlers for user events from Clerk + */ + +/** + * Handles a webhook event for user creation + * @param {ClerkUserWebhookData} data - User data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookCreated( + data: ClerkUserWebhookData, + elevated = true +): Promise { + try { + // Check if already exists + const existing = await getByClerkId(data.id) + if (existing) { + return { + message: `User ${data.id} already exists`, + success: true, + } + } + + // Get primary email if available + let email = '' + if (data.email_addresses && data.email_addresses.length > 0) { + email = data.email_addresses[0].email_address + } + + // Create new user - normally we'd include an organization ID, but + // for webhook-created users, we'll wait for the organization membership event + await createUser( + '', // Leave empty for now, will be set when user joins an organization + { + avatar: data.profile_image_url || data.image_url, + clerkId: data.id, + email, + name: `${data.first_name || ''} ${data.last_name || ''}`.trim(), + role: 'member', + verified: true, + }, + elevated + ) + + return { + message: `Created user ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process user creation webhook:', error) + return { + message: `Failed to process user creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for user update + * @param {ClerkUserWebhookData} data - User data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookUpdated( + data: ClerkUserWebhookData, + elevated = true +): Promise { + try { + // Find existing user + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `User ${data.id} not found`, + success: false, + } + } + + // Get primary email if available + let email = existing.email // Default to existing + if (data.email_addresses && data.email_addresses.length > 0) { + email = data.email_addresses[0].email_address + } + + // Update user + await updateUser( + existing.id, + { + avatar: data.profile_image_url || data.image_url || existing.avatar, + email, + name: + `${data.first_name || ''} ${data.last_name || ''}`.trim() || + existing.name, + }, + elevated + ) + + return { + message: `Updated user ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process user update webhook:', error) + return { + message: `Failed to process user update: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for user deletion + * @param {ClerkUserWebhookData} data - User deletion data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookDeleted( + data: ClerkUserWebhookData, + elevated = true +): Promise { + try { + // Find existing user + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `User ${data.id} already deleted or not found`, + success: true, + } + } + + // Delete user + await deleteUser(existing.id, elevated) + + return { + message: `Deleted user ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process user deletion webhook:', error) + return { + message: `Failed to process user deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} diff --git a/src/app/actions/services/pocketbase/userService.ts b/src/app/actions/services/pocketbase/userService.ts deleted file mode 100644 index 9fbbd98..0000000 --- a/src/app/actions/services/pocketbase/userService.ts +++ /dev/null @@ -1,365 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, - ResourceType, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { ListOptions, ListResult, User } from '@/types/types_pocketbase' - -/** - * Get a single user by ID with security validation - */ -export async function getUser(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'UserService.getUser') - } -} - -/** - * Get current authenticated user profile - */ -export async function getCurrentUser(): Promise { - try { - // This function automatically validates the current user - return await validateCurrentUser() - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getCurrentUser') - } -} - -/** - * Get a user by Clerk ID - typically used during authentication - */ -export async function getUserByClerkId(clerkId: string): Promise { - // This is primarily used during authentication flows where - // standard security checks aren't possible yet. - // However, requests should still come from server-side code only. - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUserByClerkId') - } -} - -/** - * Get users list with pagination and security checks - */ -export async function getUsersList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - needs at least READ permission - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) - - return await pb.collection('users').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUsersList') - } -} - -/** - * Get all users for an organization with security checks - */ -export async function getUsersByOrganization( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter with the correct field name - // Since users can belong to multiple organizations, we need to check expand.organizationId - return await pb.collection('users').getFullList({ - filter: `organizationId.organizationId="${organizationId}"`, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUsersByOrganization') - } -} - -/** - * Create a new user with security checks - * This is typically controlled access for admins only - */ -export async function createUser( - organizationId: string, - data: Pick< - Partial, - | 'name' - | 'email' - | 'emailVisibility' - | 'verified' - | 'avatar' - | 'phone' - | 'role' - | 'isAdmin' - | 'canLogin' - | 'clerkId' - > -): Promise { - try { - // Security check - requires ADMIN permission to create users - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set correctly with the proper field name - return await pb.collection('users').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.createUser') - } -} - -/** - * Update a user with security checks - */ -export async function updateUser( - id: string, - data: Pick< - Partial, - | 'name' - | 'email' - | 'emailVisibility' - | 'verified' - | 'avatar' - | 'phone' - | 'role' - | 'isAdmin' - | 'canLogin' - | 'lastLogin' - | 'clerkId' - > -): Promise { - try { - // Get current authenticated user - const currentUser = await validateCurrentUser() - - // Different permission checks based on who is being updated - if (id !== currentUser.id) { - // Updating someone else requires ADMIN permission - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) - } else { - // Users can update their own basic info - // But for role changes, they'd still need admin rights - if (data.role || data.isAdmin !== undefined) { - // If trying to change role or admin status, require admin permission - // Get the user's organization ID - handling possible multiple organizations - const userOrgs = currentUser.expand?.organizationId - - if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { - throw new SecurityError('User does not belong to any organization') - } - - // Use the first organization for permission check - const primaryOrgId = userOrgs[0].id - await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) - } - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Security: never allow changing certain fields - const sanitizedData = { ...data } - // Don't allow org changes or clerk ID changes - use proper type assertion - delete (sanitizedData as Record).organizationId - if (sanitizedData['clerkId']) { - delete sanitizedData.clerkId - } - - return await pb.collection('users').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.updateUser') - } -} - -/** - * Delete a user with admin permission check - */ -export async function deleteUser(id: string): Promise { - try { - // Security check - requires ADMIN permission for user deletion - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('users').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.deleteUser') - } -} - -/** - * Update user's last login time - * This is typically called during authentication flows - */ -export async function updateUserLastLogin(id: string): Promise { - try { - // Since this is called during authentication, - // we'll just verify the user exists rather than permissions - const user = await validateCurrentUser(id) - - if (!user) { - throw new SecurityError('User not found') - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').update(id, { - lastLogin: new Date().toISOString(), - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.updateUserLastLogin') - } -} - -/** - * Get the count of users in an organization - */ -export async function getUserCount(organizationId: string): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name in the filter - const result = await pb.collection('users').getList(1, 1, { - filter: `organizationId.organizationId=${organizationId}`, - skipTotal: false, - }) - - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUserCount') - } -} - -/** - * Search for users in the organization - */ -export async function searchUsers( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name in the filter and handle multi-organization relationship - return await pb.collection('users').getFullList({ - filter: pb.filter( - 'organizationId.organizationId = {:orgId} && (name ~ {:query} || email ~ {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.searchUsers') - } -} diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index d6a3af4..0436bc9 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -1,7 +1,5 @@ -import { userService } from '@/app/actions/services/pocketbase/userService' import { verifyClerkWebhook } from '@/lib/webhookUtils' import { NextRequest, NextResponse } from 'next/server' - /** * Handles webhook events from Clerk related to users */ diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index 04e54c9..2e314cc 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -35,12 +35,12 @@ export interface Organization extends BaseModel { /** * User model (auth collection) */ -export interface User extends BaseModel { +export interface AppUser extends BaseModel { email: string emailVisibility: boolean verified: boolean name: string | null - avatar?: string | null // File field + avatar?: string | null phone: string | null role: string | null isAdmin: boolean From ddcb888fc2e3596f0144b383227fd03fe22bab0e Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 18:52:34 +0200 Subject: [PATCH 28/73] feat(api): add AppUser management functionality - Implement core operations for AppUsers including create, update, delete - Add authentication-related functions to manage last login timestamps - Introduce search and listing capabilities for AppUsers with security checks - Create webhook handlers for user events from Clerk integration - Update organization membership functions to utilize new AppUser structure --- .../services/pocketbase/app-user/auth.ts | 44 ++++ .../services/pocketbase/app-user/core.ts | 203 ++++++++++++++++++ .../services/pocketbase/app-user/index.ts | 34 +++ .../services/pocketbase/app-user/internal.ts | 93 ++++++++ .../services/pocketbase/app-user/search.ts | 148 +++++++++++++ .../pocketbase/app-user/webhook-handlers.ts | 154 +++++++++++++ .../pocketbase/organization/membership.ts | 12 +- .../organization/webhook-handlers.ts | 21 +- src/types/types_pocketbase.ts | 31 ++- 9 files changed, 710 insertions(+), 30 deletions(-) create mode 100644 src/app/actions/services/pocketbase/app-user/auth.ts create mode 100644 src/app/actions/services/pocketbase/app-user/core.ts create mode 100644 src/app/actions/services/pocketbase/app-user/index.ts create mode 100644 src/app/actions/services/pocketbase/app-user/internal.ts create mode 100644 src/app/actions/services/pocketbase/app-user/search.ts create mode 100644 src/app/actions/services/pocketbase/app-user/webhook-handlers.ts diff --git a/src/app/actions/services/pocketbase/app-user/auth.ts b/src/app/actions/services/pocketbase/app-user/auth.ts new file mode 100644 index 0000000..349f22b --- /dev/null +++ b/src/app/actions/services/pocketbase/app-user/auth.ts @@ -0,0 +1,44 @@ +'use server' + +import { getAppUserByClerkId } from '@/app/actions/services/pocketbase/app-user/core' +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' +import { AppUser } from '@/types/types_pocketbase' + +/** + * Authentication-related functions for AppUsers + */ + +/** + * Update AppUser's last login time + * This is typically called during authentication flows + */ +export async function updateAppUserLastLogin( + clerkId: string +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Find user by clerkId + const appUser = await getAppUserByClerkId(clerkId) + if (!appUser) { + throw new SecurityError('AppUser not found') + } + + // Update lastLogin timestamp + return await pb.collection('app_users').update(appUser.id, { + lastLogin: new Date().toISOString(), + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.updateAppUserLastLogin') + } +} diff --git a/src/app/actions/services/pocketbase/app-user/core.ts b/src/app/actions/services/pocketbase/app-user/core.ts new file mode 100644 index 0000000..99402cb --- /dev/null +++ b/src/app/actions/services/pocketbase/app-user/core.ts @@ -0,0 +1,203 @@ +'use server' + +import { + _updateAppUser, + _createAppUser, + _deleteAppUser, +} from '@/app/actions/services/pocketbase/app-user/internal' +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateCurrentUser, + validateOrganizationAccess, + validateResourceAccess, + SecurityError, + ResourceType, + PermissionLevel, +} from '@/app/actions/services/pocketbase/securityUtils' +import { AppUser } from '@/types/types_pocketbase' + +/** + * Core AppUser operations with security validations + */ + +/** + * Get a single AppUser by ID with security validation + */ +export async function getAppUser(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('app_users').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'AppUserService.getAppUser') + } +} + +/** + * Get current authenticated AppUser profile + */ +export async function getCurrentAppUser(): Promise { + try { + // This function needs to be adjusted to work with Clerk auth + // and the custom app_users collection + const currentUser = await validateCurrentUser() + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Find the app_user record with the matching clerk ID + return await pb + .collection('app_users') + .getFirstListItem(`clerkId="${currentUser.clerkId}"`) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.getCurrentAppUser') + } +} + +/** + * Get an AppUser by Clerk ID - typically used during authentication + */ +export async function getAppUserByClerkId(clerkId: string): Promise { + // This is primarily used during authentication flows where + // standard security checks aren't possible yet. + // However, requests should still come from server-side code only. + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + try { + return await pb + .collection('app_users') + .getFirstListItem(`clerkId="${clerkId}"`) + } catch (error) { + return handlePocketBaseError(error, 'AppUserService.getAppUserByClerkId') + } +} + +/** + * Create a new AppUser with security checks + * This is typically controlled access for admins only + */ +export async function createAppUser( + organizationId: string, + data: Partial, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission to create users + await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) + } + + // Ensure organization ID is set correctly with the proper field name + return await _createAppUser({ + ...data, + organizations: organizationId ? [organizationId] : [], // Force the correct organization ID using the relation field + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.createAppUser') + } +} + +/** + * Update an AppUser with security checks + */ +export async function updateAppUser( + id: string, + data: Partial, + elevated = false +): Promise { + try { + if (!elevated) { + // Get current authenticated user + const currentUser = await validateCurrentUser() + const currentAppUser = await getAppUserByClerkId(currentUser.clerkId) + + // Different permission checks based on who is being updated + if (id !== currentAppUser.id) { + // Updating someone else requires ADMIN permission + await validateResourceAccess( + ResourceType.USER, + id, + PermissionLevel.ADMIN + ) + } else { + // Users can update their own basic info + // But for role changes, they'd still need admin rights + if (data.role || data.isAdmin !== undefined) { + // If trying to change role or admin status, require admin permission + // Get the user's organization ID - handling possible multiple organizations + const userOrgs = currentAppUser.expand?.organizations + + if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { + throw new SecurityError('User does not belong to any organization') + } + + // Use the first organization for permission check + const primaryOrgId = userOrgs[0].id + await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) + } + } + } + + // Security: never allow changing certain fields + const sanitizedData = { ...data } + // Don't allow org changes directly - use proper organization management functions + delete (sanitizedData as Record).organizations + + if (!elevated) { + // Only elevated calls (webhooks) can change the clerkId + delete sanitizedData.clerkId + } + + return await _updateAppUser(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.updateAppUser') + } +} + +/** + * Delete an AppUser with admin permission check + */ +export async function deleteAppUser( + id: string, + elevated = false +): Promise { + try { + if (!elevated) { + // Security check - requires ADMIN permission for user deletion + await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) + } + + return await _deleteAppUser(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.deleteAppUser') + } +} diff --git a/src/app/actions/services/pocketbase/app-user/index.ts b/src/app/actions/services/pocketbase/app-user/index.ts new file mode 100644 index 0000000..22f2828 --- /dev/null +++ b/src/app/actions/services/pocketbase/app-user/index.ts @@ -0,0 +1,34 @@ +/** + * AppUser service - public exports + */ + +// Core operations +export { + getAppUser, + getCurrentAppUser, + getAppUserByClerkId, + createAppUser, + updateAppUser, + deleteAppUser, +} from '@/app/actions/services/pocketbase/app-user/core' + +// Search functions +export { + getAppUsersList, + getAppUsersByOrganization, + getAppUserCount, + searchAppUsers, +} from '@/app/actions/services/pocketbase/app-user/search' + +// Authentication functions +export { updateAppUserLastLogin } from '@/app/actions/services/pocketbase/app-user/auth' + +// Webhook handlers +export { + handleWebhookCreated, + handleWebhookUpdated, + handleWebhookDeleted, +} from '@/app/actions/services/pocketbase/app-user/webhook-handlers' + +// Internal functions that might be used by other services +export { getByClerkId } from '@/app/actions/services/pocketbase/app-user/internal' diff --git a/src/app/actions/services/pocketbase/app-user/internal.ts b/src/app/actions/services/pocketbase/app-user/internal.ts new file mode 100644 index 0000000..bad7caf --- /dev/null +++ b/src/app/actions/services/pocketbase/app-user/internal.ts @@ -0,0 +1,93 @@ +'use server' + +import { getPocketBase, handlePocketBaseError } from "@/app/actions/services/pocketbase/baseService" +import { AppUser } from "@/types/types_pocketbase" + +/** + * Internal methods for AppUser management + * These methods have no security checks and should only be called + * from secured public API methods + */ + +/** + * Internal: Update AppUser without security checks + * @param id AppUser ID + * @param data AppUser data + */ +export async function _updateAppUser( + id: string, + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('app_users').update(id, data) + } catch (error) { + return handlePocketBaseError(error, 'AppUserService._updateAppUser') + } +} + +/** + * Internal: Create AppUser without security checks + * @param data AppUser data + */ +export async function _createAppUser( + data: Partial +): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('app_users').create(data) + } catch (error) { + return handlePocketBaseError(error, 'AppUserService._createAppUser') + } +} + +/** + * Internal: Delete AppUser without security checks + * @param id AppUser ID + */ +export async function _deleteAppUser(id: string): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('app_users').delete(id) + return true + } catch (error) { + handlePocketBaseError(error, 'AppUserService._deleteAppUser') + return false + } +} + +/** + * Get an AppUser by Clerk ID + * @param clerkId Clerk user ID + * @returns AppUser record or null if not found + */ +export async function getByClerkId(clerkId: string): Promise { + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const user = await pb.collection('app_users').getFirstListItem(`clerkId="${clerkId}"`) + return user + } catch (error) { + // If user not found, return null instead of throwing + if (error instanceof Error && error.message.includes('404')) { + return null + } + console.error('Error fetching app user by clerk ID:', error) + return null + } +} \ No newline at end of file diff --git a/src/app/actions/services/pocketbase/app-user/search.ts b/src/app/actions/services/pocketbase/app-user/search.ts new file mode 100644 index 0000000..c48f7f5 --- /dev/null +++ b/src/app/actions/services/pocketbase/app-user/search.ts @@ -0,0 +1,148 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + PermissionLevel, + SecurityError, +} from '@/app/actions/services/pocketbase/securityUtils' +import { ListOptions, ListResult, AppUser } from '@/types/types_pocketbase' + +/** + * Search and listing functions for AppUsers + */ + +/** + * Get AppUsers list with pagination and security checks + */ +export async function getAppUsersList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check - needs at least READ permission + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = `organizations ~ "${organizationId}"${additionalFilter ? ` && (${additionalFilter})` : ''}` + + return await pb.collection('app_users').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.getAppUsersList') + } +} + +/** + * Get all AppUsers for an organization with security checks + */ +export async function getAppUsersByOrganization( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with this organization in their organizations relation + return await pb.collection('app_users').getFullList({ + filter: `organizations ~ "${organizationId}"`, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'AppUserService.getAppUsersByOrganization' + ) + } +} + +/** + * Get the count of AppUsers in an organization + */ +export async function getAppUserCount(organizationId: string): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with this organization in their organizations relation + const result = await pb.collection('app_users').getList(1, 1, { + filter: `organizations ~ "${organizationId}"`, + skipTotal: false, + }) + + return result.totalItems + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.getAppUserCount') + } +} + +/** + * Search for AppUsers in the organization + */ +export async function searchAppUsers( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Query users with search conditions + return await pb.collection('app_users').getFullList({ + filter: pb.filter( + 'organizations ~ {:orgId} && (name ~ {:query} || email ~ {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'AppUserService.searchAppUsers') + } +} diff --git a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts new file mode 100644 index 0000000..0083ba0 --- /dev/null +++ b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts @@ -0,0 +1,154 @@ +'use server' + +import { + createAppUser, + updateAppUser, + deleteAppUser, +} from '@/app/actions/services/pocketbase/app-user/core' +import { getByClerkId } from '@/app/actions/services/pocketbase/app-user/internal' +import { ClerkUserWebhookData, WebhookProcessingResult } from '@/types/webhooks' + +/** + * Webhook handlers for AppUser events from Clerk + */ + +/** + * Handles a webhook event for user creation + * @param {ClerkUserWebhookData} data - User data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookCreated( + data: ClerkUserWebhookData, + elevated = true +): Promise { + try { + // Check if already exists + const existing = await getByClerkId(data.id) + if (existing) { + return { + message: `AppUser ${data.id} already exists`, + success: true, + } + } + + // Get primary email if available + let email = '' + if (data.email_addresses && data.email_addresses.length > 0) { + email = data.email_addresses[0].email_address + } + + // Create new AppUser - normally we'd include an organization ID, but + // for webhook-created users, we'll wait for the organization membership event + await createAppUser( + '', // Leave empty for now, will be set when user joins an organization + { + clerkId: data.id, + email, + name: `${data.first_name || ''} ${data.last_name || ''}`.trim(), + role: 'member', + verified: true, + // No password fields needed with custom collection + }, + elevated + ) + + return { + message: `Created AppUser ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process user creation webhook:', error) + return { + message: `Failed to process user creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for user update + * @param {ClerkUserWebhookData} data - User data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookUpdated( + data: ClerkUserWebhookData, + elevated = true +): Promise { + try { + // Find existing user + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `AppUser ${data.id} not found`, + success: false, + } + } + + // Get primary email if available + let email = existing.email // Default to existing + if (data.email_addresses && data.email_addresses.length > 0) { + email = data.email_addresses[0].email_address + } + + // Update user + await updateAppUser( + existing.id, + { + email, + name: + `${data.first_name || ''} ${data.last_name || ''}`.trim() || + existing.name, + }, + elevated + ) + + return { + message: `Updated AppUser ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process user update webhook:', error) + return { + message: `Failed to process user update: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} + +/** + * Handles a webhook event for user deletion + * @param {ClerkUserWebhookData} data - User deletion data from Clerk + * @param {boolean} elevated - Whether operation has elevated permissions + * @returns {Promise} Processing result + */ +export async function handleWebhookDeleted( + data: ClerkUserWebhookData, + elevated = true +): Promise { + try { + // Find existing user + const existing = await getByClerkId(data.id) + if (!existing) { + return { + message: `AppUser ${data.id} already deleted or not found`, + success: true, + } + } + + // Delete user + await deleteAppUser(existing.id, elevated) + + return { + message: `Deleted AppUser ${data.id}`, + success: true, + } + } catch (error) { + console.error('Failed to process user deletion webhook:', error) + return { + message: `Failed to process user deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} diff --git a/src/app/actions/services/pocketbase/organization/membership.ts b/src/app/actions/services/pocketbase/organization/membership.ts index f488c6d..05eebb8 100644 --- a/src/app/actions/services/pocketbase/organization/membership.ts +++ b/src/app/actions/services/pocketbase/organization/membership.ts @@ -9,7 +9,7 @@ import { PermissionLevel, SecurityError, } from '@/app/actions/services/pocketbase/securityUtils' -import { User } from '@/types/types_pocketbase' +import { AppUser } from '@/types/types_pocketbase' /** * Membership management functions for organizations @@ -23,7 +23,7 @@ import { User } from '@/types/types_pocketbase' export async function _addUserToOrganization( userId: string, organizationId: string -): Promise { +): Promise { try { const pb = await getPocketBase() if (!pb) { @@ -67,7 +67,7 @@ export async function _addUserToOrganization( export async function _removeUserFromOrganization( userId: string, organizationId: string -): Promise { +): Promise { try { const pb = await getPocketBase() if (!pb) { @@ -107,7 +107,7 @@ export async function addUserToOrganization( userId: string, organizationId: string, elevated = false -): Promise { +): Promise { try { if (!elevated) { // Security check - requires ADMIN permission for member management @@ -133,7 +133,7 @@ export async function removeUserFromOrganization( userId: string, organizationId: string, elevated = false -): Promise { +): Promise { try { if (!elevated) { // Security check - requires ADMIN permission for member management @@ -157,7 +157,7 @@ export async function removeUserFromOrganization( */ export async function getOrganizationUsers( organizationId: string -): Promise { +): Promise { try { // Security check - validates user has access to this organization await validateOrganizationAccess(organizationId, PermissionLevel.READ) diff --git a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts index 477b898..68ee55a 100644 --- a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts +++ b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts @@ -1,5 +1,6 @@ 'use server' +import * as appUserService from '@/app/actions/services/pocketbase/app-user' import { createOrganization, updateOrganization, @@ -171,7 +172,9 @@ export async function handleMembershipWebhookCreated( } } - const user = await userService.getByClerkId(data.public_user_data.user_id) + const user = await appUserService.getByClerkId( + data.public_user_data.user_id + ) if (!user) { return { message: `User with Clerk ID ${data.public_user_data.user_id} not found`, @@ -184,7 +187,7 @@ export async function handleMembershipWebhookCreated( // Update user role if needed if (data.role === 'admin') { - await userService.updateUser( + await appUserService.updateAppUser( user.id, { role: 'admin', @@ -226,7 +229,9 @@ export async function handleMembershipWebhookUpdated( } } - const user = await userService.getByClerkId(data.public_user_data.user_id) + const user = await appUserService.getByClerkId( + data.public_user_data.user_id + ) if (!user) { return { message: `User with Clerk ID ${data.public_user_data.user_id} not found`, @@ -236,15 +241,15 @@ export async function handleMembershipWebhookUpdated( // Update user role if needed if (data.role === 'admin') { - await userService.updateUser( + await appUserService.updateAppUser( user.id, { role: 'admin', }, elevated ) - } else if (data.role === 'basic_member') { - await userService.updateUser( + } else if (data.role === 'member') { + await appUserService.updateAppUser( user.id, { role: 'member', @@ -286,7 +291,9 @@ export async function handleMembershipWebhookDeleted( } } - const user = await userService.getByClerkId(data.public_user_data.user_id) + const user = await appUserService.getByClerkId( + data.public_user_data.user_id + ) if (!user) { return { message: `User with Clerk ID ${data.public_user_data.user_id} not found`, diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index 2e314cc..f2b312c 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -35,26 +35,23 @@ export interface Organization extends BaseModel { /** * User model (auth collection) */ -export interface AppUser extends BaseModel { +export interface AppUser { + id: string + name: string email: string - emailVisibility: boolean - verified: boolean - name: string | null - avatar?: string | null - phone: string | null - role: string | null - isAdmin: boolean - canLogin: boolean - lastLogin?: string - - // Clerk integration field + phone?: string + role?: string + isAdmin?: boolean + verified?: boolean + emailVisibility?: boolean clerkId?: string - - // Expanded relations - // the user can be part of multiple organizations + lastLogin?: string + created?: string + updated?: string expand?: { - organizationId?: Organization[] + organizations?: Organization[] } + organizations?: string[] // Organization IDs } /** @@ -109,7 +106,7 @@ export interface Assignment extends BaseModel { expand?: { organizationId?: Organization equipmentId?: Equipment - assignedToUserId?: User + assignedToUserId?: AppUser assignedToProjectId?: Project } } From 31a414264cd13e794e4ba9eb8ecef708fc280291 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:02:05 +0200 Subject: [PATCH 29/73] feat(security): refactor security utilities and validation - Move permission levels, resource types, and SecurityError class to a new file - Update user validation functions to use AppUser type instead of User - Change error handling in validation functions to throw generic Error - Refactor webhook processing logic for better clarity and debugging - Enhance webhook signature verification with detailed logging --- .../services/pocketbase/securityUtils.ts | 51 ++++------------ src/app/actions/services/securyUtilsTools.ts | 29 +++++++++ src/app/api/webhook/clerk/user/route.ts | 61 +++++++++++-------- src/lib/webhookUtils.ts | 48 +++++++++++---- 4 files changed, 111 insertions(+), 78 deletions(-) create mode 100644 src/app/actions/services/securyUtilsTools.ts diff --git a/src/app/actions/services/pocketbase/securityUtils.ts b/src/app/actions/services/pocketbase/securityUtils.ts index d214389..e47f593 100644 --- a/src/app/actions/services/pocketbase/securityUtils.ts +++ b/src/app/actions/services/pocketbase/securityUtils.ts @@ -1,45 +1,20 @@ 'use server' import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' -import { User } from '@/types/types_pocketbase' +import { + SecurityError, + PermissionLevel, + ResourceType, +} from '@/app/actions/services/securyUtilsTools' +import { AppUser } from '@/types/types_pocketbase' import { auth } from '@clerk/nextjs/server' -/** - * User permission levels - */ -export enum PermissionLevel { - ADMIN = 'admin', - READ = 'read', - WRITE = 'write', -} - -/** - * Resource types for permission checks - */ -export enum ResourceType { - ASSIGNMENT = 'assignment', - EQUIPMENT = 'equipment', - ORGANIZATION = 'organization', - PROJECT = 'project', - USER = 'user', -} - -/** - * Error thrown when security checks fail - */ -export class SecurityError extends Error { - constructor(message: string) { - super(message) - this.name = 'SecurityError' - } -} - /** * Validates a user ID against the current authenticated user * @param userId The user ID to validate * @throws {SecurityError} If the user ID is invalid or unauthorized */ -export async function validateCurrentUser(userId?: string): Promise { +export async function validateCurrentUser(userId?: string): Promise { // Get Clerk auth context const { userId: clerkUserId } = await auth() @@ -75,12 +50,12 @@ export async function validateCurrentUser(userId?: string): Promise { * @param organizationId The organization ID to validate * @param permission The required permission level * @returns The validated user and organization - * @throws {SecurityError} If access is unauthorized + * @throws {Error} If access is unauthorized */ export async function validateOrganizationAccess( organizationId: string, permission: PermissionLevel = PermissionLevel.READ -): Promise<{ user: User; organizationId: string }> { +): Promise<{ user: AppUser; organizationId: string }> { // Get authenticated user const user = await validateCurrentUser() @@ -116,13 +91,13 @@ export async function validateOrganizationAccess( * @param resourceId The resource ID * @param permission The required permission level * @returns The validated user and organization ID - * @throws {SecurityError} If access is unauthorized + * @throws {Error} If access is unauthorized */ export async function validateResourceAccess( resourceType: ResourceType, resourceId: string, permission: PermissionLevel = PermissionLevel.READ -): Promise<{ user: User; organizationId: string }> { +): Promise<{ user: AppUser; organizationId: string }> { const pb = await getPocketBase() if (!pb) { throw new SecurityError('Database connection error') @@ -150,10 +125,10 @@ export async function validateResourceAccess( * @param additionalFilter Optional additional filter expression * @returns A complete filter string with organization filtering */ -export function createOrganizationFilter( +export async function createOrganizationFilter( organizationId: string, additionalFilter?: string -): string { +): Promise { const orgFilter = `organization="${organizationId}"` if (!additionalFilter) { diff --git a/src/app/actions/services/securyUtilsTools.ts b/src/app/actions/services/securyUtilsTools.ts new file mode 100644 index 0000000..273c639 --- /dev/null +++ b/src/app/actions/services/securyUtilsTools.ts @@ -0,0 +1,29 @@ +/** + * User permission levels + */ +export enum PermissionLevel { + ADMIN = 'admin', + READ = 'read', + WRITE = 'write', +} + +/** + * Resource types for permission checks + */ +export enum ResourceType { + ASSIGNMENT = 'assignment', + EQUIPMENT = 'equipment', + ORGANIZATION = 'organization', + PROJECT = 'project', + USER = 'user', +} + +/** + * Error thrown when security checks fail + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message) + this.name = 'SecurityError' + } +} diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index 0436bc9..35a900f 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -1,48 +1,55 @@ +import * as appUserService from '@/app/actions/services/pocketbase/app-user' import { verifyClerkWebhook } from '@/lib/webhookUtils' import { NextRequest, NextResponse } from 'next/server' /** * Handles webhook events from Clerk related to users */ export async function POST(req: NextRequest) { - // Verify webhook signature - const isValid = await verifyClerkWebhook( - req, - process.env.CLERK_WEBHOOK_SECRET_USER - ) + console.log('Received Clerk webhook request') + + // Log the entire request for debugging + console.log('req', req) + + const webhookSecret = process.env.CLERK_WEBHOOK_SECRET + + // Verify the webhook signature + const isValid = await verifyClerkWebhook(req, webhookSecret) + console.log('isValid', isValid) if (!isValid) { console.error('Invalid webhook signature for user event') - return new NextResponse('Invalid signature', { status: 401 }) + return new NextResponse('Unauthorized', { status: 401 }) } try { - // Get the request body - const clone = req.clone() - const body = await clone.json() + // Get the webhook body + const body = await req.json() + console.log('Webhook body:', JSON.stringify(body, null, 2)) + + // Process based on event type const { data, type } = body - console.log(`Processing user webhook: ${type}`) - - // Handle different user events - switch (type) { - case 'user.created': - await handleUserCreated(data) - break - case 'user.updated': - await handleUserUpdated(data) - break - case 'user.deleted': - await handleUserDeleted(data) - break - default: - console.log(`Unhandled user event type: ${type}`) + if (type.startsWith('user.')) { + if (type === 'user.created') { + const result = await appUserService.handleWebhookCreated(data, true) + return NextResponse.json(result) + } else if (type === 'user.updated') { + const result = await appUserService.handleWebhookUpdated(data, true) + return NextResponse.json(result) + } else if (type === 'user.deleted') { + const result = await appUserService.handleWebhookDeleted(data, true) + return NextResponse.json(result) + } } - return NextResponse.json({ success: true }) + return NextResponse.json({ + message: `Unhandled event type: ${type}`, + success: false, + }) } catch (error) { - console.error('Error processing user webhook:', error) + console.error('Error processing webhook:', error) return NextResponse.json( - { error: 'Internal server error' }, + { message: 'Error processing webhook', success: false }, { status: 500 } ) } diff --git a/src/lib/webhookUtils.ts b/src/lib/webhookUtils.ts index f981c1e..0e50ef1 100644 --- a/src/lib/webhookUtils.ts +++ b/src/lib/webhookUtils.ts @@ -2,7 +2,7 @@ import * as crypto from 'crypto' import { NextRequest } from 'next/server' /** - * Validates a webhook signature from Clerk - simplified version + * Validates a webhook signature from Clerk */ export async function verifyClerkWebhook( req: NextRequest, @@ -23,27 +23,49 @@ export async function verifyClerkWebhook( return false } + // Log the headers for debugging + console.log('Webhook Headers:', { + svix_id, + svix_signature, + svix_timestamp, + }) + // Get the raw body const payload = await req.text() const signaturePayload = `${svix_id}.${svix_timestamp}.${payload}` - // Verify signatures - const expectedSignatures = svix_signature.split(' ') - const signatures = expectedSignatures.map(sig => { + // Clerk signatures are in the format "v1,signature1 v1,signature2" + const signatures = svix_signature.split(' ') + + for (const sig of signatures) { + // Format is "version,signature" const [version, signature] = sig.split(',') - return { signature, version } - }) - // Check if any signature matches - return signatures.some(({ signature }) => { + if (!signature || !version) { + continue + } + try { + // Create HMAC with the secret const hmac = crypto.createHmac('sha256', secret) hmac.update(signaturePayload) - const digest = hmac.digest('hex') - return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature)) + const digest = hmac.digest('base64') + + console.log('Verification attempt:', { + computedSignature: digest, + expectedSignature: signature, + version, + }) + + // Simple string comparison + if (signature === digest) { + return true + } } catch (error) { - console.error('Error verifying signature:', error) - return false + console.error('Error in signature verification:', error) } - }) + } + + console.error('No matching signatures found') + return false } From 7d3c4a20067512e661565ae34014b6dcff7b5af7 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:07:27 +0200 Subject: [PATCH 30/73] feat(api): update collection names to match new schema - Change all references from 'app_users' to 'AppUser' - Ensure lastLogin timestamp updates correctly - Modify user retrieval methods for consistency - Update webhook verification process with Svix library --- .../services/pocketbase/app-user/auth.ts | 2 +- .../services/pocketbase/app-user/core.ts | 4 +- .../services/pocketbase/app-user/internal.ts | 23 ++++--- .../services/pocketbase/app-user/search.ts | 8 +-- src/app/api/webhook/clerk/user/route.ts | 23 +++---- src/lib/webhookUtils.ts | 67 ++++++------------- 6 files changed, 51 insertions(+), 76 deletions(-) diff --git a/src/app/actions/services/pocketbase/app-user/auth.ts b/src/app/actions/services/pocketbase/app-user/auth.ts index 349f22b..ac7c59e 100644 --- a/src/app/actions/services/pocketbase/app-user/auth.ts +++ b/src/app/actions/services/pocketbase/app-user/auth.ts @@ -32,7 +32,7 @@ export async function updateAppUserLastLogin( } // Update lastLogin timestamp - return await pb.collection('app_users').update(appUser.id, { + return await pb.collection('AppUser').update(appUser.id, { lastLogin: new Date().toISOString(), }) } catch (error) { diff --git a/src/app/actions/services/pocketbase/app-user/core.ts b/src/app/actions/services/pocketbase/app-user/core.ts index 99402cb..0077d55 100644 --- a/src/app/actions/services/pocketbase/app-user/core.ts +++ b/src/app/actions/services/pocketbase/app-user/core.ts @@ -61,7 +61,7 @@ export async function getCurrentAppUser(): Promise { // Find the app_user record with the matching clerk ID return await pb - .collection('app_users') + .collection('AppUser') .getFirstListItem(`clerkId="${currentUser.clerkId}"`) } catch (error) { if (error instanceof SecurityError) { @@ -85,7 +85,7 @@ export async function getAppUserByClerkId(clerkId: string): Promise { try { return await pb - .collection('app_users') + .collection('AppUser') .getFirstListItem(`clerkId="${clerkId}"`) } catch (error) { return handlePocketBaseError(error, 'AppUserService.getAppUserByClerkId') diff --git a/src/app/actions/services/pocketbase/app-user/internal.ts b/src/app/actions/services/pocketbase/app-user/internal.ts index bad7caf..c6f3a4d 100644 --- a/src/app/actions/services/pocketbase/app-user/internal.ts +++ b/src/app/actions/services/pocketbase/app-user/internal.ts @@ -1,7 +1,10 @@ 'use server' -import { getPocketBase, handlePocketBaseError } from "@/app/actions/services/pocketbase/baseService" -import { AppUser } from "@/types/types_pocketbase" +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { AppUser } from '@/types/types_pocketbase' /** * Internal methods for AppUser management @@ -24,7 +27,7 @@ export async function _updateAppUser( throw new Error('Failed to connect to PocketBase') } - return await pb.collection('app_users').update(id, data) + return await pb.collection('AppUser').update(id, data) } catch (error) { return handlePocketBaseError(error, 'AppUserService._updateAppUser') } @@ -34,16 +37,14 @@ export async function _updateAppUser( * Internal: Create AppUser without security checks * @param data AppUser data */ -export async function _createAppUser( - data: Partial -): Promise { +export async function _createAppUser(data: Partial): Promise { try { const pb = await getPocketBase() if (!pb) { throw new Error('Failed to connect to PocketBase') } - return await pb.collection('app_users').create(data) + return await pb.collection('AppUser').create(data) } catch (error) { return handlePocketBaseError(error, 'AppUserService._createAppUser') } @@ -60,7 +61,7 @@ export async function _deleteAppUser(id: string): Promise { throw new Error('Failed to connect to PocketBase') } - await pb.collection('app_users').delete(id) + await pb.collection('AppUser').delete(id) return true } catch (error) { handlePocketBaseError(error, 'AppUserService._deleteAppUser') @@ -80,7 +81,9 @@ export async function getByClerkId(clerkId: string): Promise { throw new Error('Failed to connect to PocketBase') } - const user = await pb.collection('app_users').getFirstListItem(`clerkId="${clerkId}"`) + const user = await pb + .collection('AppUser') + .getFirstListItem(`clerkId="${clerkId}"`) return user } catch (error) { // If user not found, return null instead of throwing @@ -90,4 +93,4 @@ export async function getByClerkId(clerkId: string): Promise { console.error('Error fetching app user by clerk ID:', error) return null } -} \ No newline at end of file +} diff --git a/src/app/actions/services/pocketbase/app-user/search.ts b/src/app/actions/services/pocketbase/app-user/search.ts index c48f7f5..82c198a 100644 --- a/src/app/actions/services/pocketbase/app-user/search.ts +++ b/src/app/actions/services/pocketbase/app-user/search.ts @@ -41,7 +41,7 @@ export async function getAppUsersList( // Apply organization filter to ensure data isolation const filter = `organizations ~ "${organizationId}"${additionalFilter ? ` && (${additionalFilter})` : ''}` - return await pb.collection('app_users').getList(page, perPage, { + return await pb.collection('AppUser').getList(page, perPage, { ...rest, filter, }) @@ -69,7 +69,7 @@ export async function getAppUsersByOrganization( } // Query users with this organization in their organizations relation - return await pb.collection('app_users').getFullList({ + return await pb.collection('AppUser').getFullList({ filter: `organizations ~ "${organizationId}"`, sort: 'name', }) @@ -98,7 +98,7 @@ export async function getAppUserCount(organizationId: string): Promise { } // Query users with this organization in their organizations relation - const result = await pb.collection('app_users').getList(1, 1, { + const result = await pb.collection('AppUser').getList(1, 1, { filter: `organizations ~ "${organizationId}"`, skipTotal: false, }) @@ -129,7 +129,7 @@ export async function searchAppUsers( } // Query users with search conditions - return await pb.collection('app_users').getFullList({ + return await pb.collection('AppUser').getFullList({ filter: pb.filter( 'organizations ~ {:orgId} && (name ~ {:query} || email ~ {:query})', { diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index 35a900f..4921a21 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -7,27 +7,24 @@ import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { console.log('Received Clerk webhook request') - // Log the entire request for debugging - console.log('req', req) + const webhookSecret = process.env.CLERK_WEBHOOK_SECRET_USER - const webhookSecret = process.env.CLERK_WEBHOOK_SECRET + // Verify the webhook + const { payload, success } = await verifyClerkWebhook( + req.clone(), + webhookSecret + ) - // Verify the webhook signature - const isValid = await verifyClerkWebhook(req, webhookSecret) - console.log('isValid', isValid) - - if (!isValid) { + if (!success || !payload) { console.error('Invalid webhook signature for user event') return new NextResponse('Unauthorized', { status: 401 }) } try { - // Get the webhook body - const body = await req.json() - console.log('Webhook body:', JSON.stringify(body, null, 2)) - // Process based on event type - const { data, type } = body + const { data, type } = payload + + console.log('Webhook verified successfully:', { type }) if (type.startsWith('user.')) { if (type === 'user.created') { diff --git a/src/lib/webhookUtils.ts b/src/lib/webhookUtils.ts index 0e50ef1..0efe6ef 100644 --- a/src/lib/webhookUtils.ts +++ b/src/lib/webhookUtils.ts @@ -1,16 +1,16 @@ -import * as crypto from 'crypto' import { NextRequest } from 'next/server' +import { Webhook } from 'svix' /** - * Validates a webhook signature from Clerk + * Validates a webhook signature from Clerk using the official Svix library */ export async function verifyClerkWebhook( req: NextRequest, secret: string | undefined -): Promise { +): Promise<{ success: boolean; payload?: any }> { if (!secret) { console.error('Missing Clerk webhook secret') - return false + return { success: false } } // Get the signature headers @@ -20,52 +20,27 @@ export async function verifyClerkWebhook( if (!svix_id || !svix_timestamp || !svix_signature) { console.error('Missing Svix headers') - return false + return { success: false } } - // Log the headers for debugging - console.log('Webhook Headers:', { - svix_id, - svix_signature, - svix_timestamp, - }) - // Get the raw body const payload = await req.text() - const signaturePayload = `${svix_id}.${svix_timestamp}.${payload}` - - // Clerk signatures are in the format "v1,signature1 v1,signature2" - const signatures = svix_signature.split(' ') - - for (const sig of signatures) { - // Format is "version,signature" - const [version, signature] = sig.split(',') - - if (!signature || !version) { - continue - } - try { - // Create HMAC with the secret - const hmac = crypto.createHmac('sha256', secret) - hmac.update(signaturePayload) - const digest = hmac.digest('base64') - - console.log('Verification attempt:', { - computedSignature: digest, - expectedSignature: signature, - version, - }) - - // Simple string comparison - if (signature === digest) { - return true - } - } catch (error) { - console.error('Error in signature verification:', error) - } + try { + // Create a new Webhook instance with the secret + const wh = new Webhook(secret) + + // Verify the webhook payload + const event = wh.verify(payload, { + 'svix-id': svix_id, + 'svix-signature': svix_signature, + 'svix-timestamp': svix_timestamp, + }) + + // If we reach here, the verification succeeded + return { payload: JSON.parse(payload), success: true } + } catch (error) { + console.error('Webhook verification error:', error) + return { success: false } } - - console.error('No matching signatures found') - return false } From ff8e8b07ffe55d8ad800072938cce6e3643351c3 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:12:19 +0200 Subject: [PATCH 31/73] feat(api): enhance AppUser creation and fetching - Add logging for user creation and fetching processes - Ensure clerkId is mandatory when creating a new AppUser - Improve error handling for user not found scenarios - Update webhook handler to create user if not found instead of failing --- .../services/pocketbase/app-user/internal.ts | 45 ++++++++++++++----- .../pocketbase/app-user/webhook-handlers.ts | 19 ++++---- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/app/actions/services/pocketbase/app-user/internal.ts b/src/app/actions/services/pocketbase/app-user/internal.ts index c6f3a4d..9190458 100644 --- a/src/app/actions/services/pocketbase/app-user/internal.ts +++ b/src/app/actions/services/pocketbase/app-user/internal.ts @@ -44,9 +44,20 @@ export async function _createAppUser(data: Partial): Promise { throw new Error('Failed to connect to PocketBase') } - return await pb.collection('AppUser').create(data) + console.log('Creating new AppUser with data:', data) + + // Make sure clerkId is included + if (!data.clerkId) { + throw new Error('clerkId is required when creating a new AppUser') + } + + const newUser = await pb.collection('AppUser').create(data) + console.log('New AppUser created:', newUser) + + return newUser } catch (error) { - return handlePocketBaseError(error, 'AppUserService._createAppUser') + console.error('Error creating app user:', error) + throw error } } @@ -81,15 +92,29 @@ export async function getByClerkId(clerkId: string): Promise { throw new Error('Failed to connect to PocketBase') } - const user = await pb - .collection('AppUser') - .getFirstListItem(`clerkId="${clerkId}"`) - return user - } catch (error) { - // If user not found, return null instead of throwing - if (error instanceof Error && error.message.includes('404')) { - return null + console.log(`Searching for AppUser with clerkId: ${clerkId}`) + + // Try to find the user + try { + const user = await pb + .collection('AppUser') + .getFirstListItem(`clerkId="${clerkId}"`) + + console.log('User found:', user) + return user + } catch (error) { + // Check if this is a "not found" error + if ( + error instanceof Error && + (error.message.includes('404') || error.message.includes('not found')) + ) { + console.log(`No user found with clerkId: ${clerkId}`) + return null + } + // Otherwise rethrow the error + throw error } + } catch (error) { console.error('Error fetching app user by clerk ID:', error) return null } diff --git a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts index 0083ba0..fc0b4e5 100644 --- a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts +++ b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts @@ -8,10 +8,6 @@ import { import { getByClerkId } from '@/app/actions/services/pocketbase/app-user/internal' import { ClerkUserWebhookData, WebhookProcessingResult } from '@/types/webhooks' -/** - * Webhook handlers for AppUser events from Clerk - */ - /** * Handles a webhook event for user creation * @param {ClerkUserWebhookData} data - User data from Clerk @@ -77,15 +73,22 @@ export async function handleWebhookUpdated( elevated = true ): Promise { try { + console.log('Processing user update webhook for clerkId:', data.id) + // Find existing user const existing = await getByClerkId(data.id) + if (!existing) { - return { - message: `AppUser ${data.id} not found`, - success: false, - } + console.log( + `AppUser with clerkId ${data.id} not found, creating new user` + ) + // If user doesn't exist, create it instead of updating + return await handleWebhookCreated(data, elevated) } + // Continue with the update logic... + console.log(`Updating existing AppUser: ${existing.id}`) + // Get primary email if available let email = existing.email // Default to existing if (data.email_addresses && data.email_addresses.length > 0) { From 52e5e45f85e967a872b8f2e75fe4492b6d96340e Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:16:50 +0200 Subject: [PATCH 32/73] feat(sync): enhance user and organization syncing - Add server-side support for sync middleware - Refactor user data handling in syncUserToPocketBase - Improve error messages for missing Clerk IDs - Update organization syncing logic with better error handling - Introduce logging for create/update operations in organizations --- .../services/clerk-sync/syncMiddleware.ts | 2 + .../services/clerk-sync/syncService.ts | 132 ++++++++---------- .../services/pocketbase/app-user/search.ts | 2 +- .../pocketbase/organization/internal.ts | 62 +++++--- 4 files changed, 102 insertions(+), 96 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts index edcda66..d8a5f56 100644 --- a/src/app/actions/services/clerk-sync/syncMiddleware.ts +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -1,3 +1,5 @@ +'use server' + import { secureCache } from '@/app/actions/services/clerk-sync/cacheService' import { syncUserToPocketBase, diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index ff7d860..45a7cd2 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -1,5 +1,15 @@ +import { + getByClerkId, + _createAppUser, + _updateAppUser, +} from '@/app/actions/services/pocketbase/app-user/internal' // src/app/actions/services/clerk-sync/syncService.ts import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +import { + getOrganizationByClerkId, + _createOrganization, + _updateOrganization, +} from '@/app/actions/services/pocketbase/organization/internal' import { User, Organization } from '@/types/types_pocketbase' import { clerkClient } from '@clerk/nextjs/server' @@ -64,59 +74,50 @@ type ClerkMembershipData = { * @param userData The user data from Clerk webhook or API * @returns The created or updated user */ -export async function syncUserToPocketBase( - userData: ClerkUserData -): Promise { +export async function syncUserToPocketBase(user: User): Promise { try { - const clerkId = userData.id + const { + emailAddresses, + firstName, + id: clerkId, + lastName, + ...otherUserData + } = user + if (!clerkId) { - throw new Error('Missing Clerk ID in user data') + throw new Error('Clerk user ID is required for syncing') } - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') + // Get primary email + const primaryEmail = emailAddresses.find( + email => email.id === user.primaryEmailAddressId + ) + if (!primaryEmail) { + throw new Error('User must have a primary email address') } - // Try to find the existing user first - let pbUser: User | null = null - try { - pbUser = await pb - .collection('users') - .getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - // User doesn't exist yet, we'll create a new one - pbUser = null - console.error('Error syncing user to PocketBase:', error) - } + // Try to find existing AppUser + const existingUser = await getByClerkId(clerkId) - // Prepare the user data + // Prepare user data const userDataToSync = { - avatar: userData.image_url || null, - canLogin: true, - clerkId: clerkId, - email: userData.email_addresses?.[0]?.email_address || '', - emailVisibility: true, - isAdmin: userData.public_metadata?.isAdmin || false, - lastLogin: new Date().toISOString(), + clerkId, + email: primaryEmail.emailAddress, name: - `${userData.first_name || ''} ${userData.last_name || ''}`.trim() || - userData.username || - 'User', - phone: userData.phone_numbers?.[0]?.phone_number || null, - role: userData.public_metadata?.role || 'user', - verified: - userData.email_addresses?.[0]?.verification?.status === 'verified' || - false, + firstName && lastName + ? `${firstName} ${lastName}` + : user.username || 'Unknown', + verified: primaryEmail.verification?.status === 'verified', + // Add other fields as needed } - // Update or create the user - if (pbUser) { - console.info(`Updating existing user ${clerkId} in PocketBase`) - return await pb.collection('users').update(pbUser.id, userDataToSync) + // Update or create + if (existingUser) { + console.info(`Updating existing AppUser ${clerkId} in PocketBase`) + return await _updateAppUser(existingUser.id, userDataToSync) } else { - console.info(`Creating new user ${clerkId} in PocketBase`) - return await pb.collection('users').create(userDataToSync) + console.info(`Creating new AppUser ${clerkId} in PocketBase`) + return await _createAppUser(userDataToSync) } } catch (error) { console.error('Error syncing user to PocketBase:', error) @@ -130,50 +131,29 @@ export async function syncUserToPocketBase( * @returns The created or updated organization */ export async function syncOrganizationToPocketBase( - orgData: ClerkOrganizationData + organization: ClerkOrganization ): Promise { try { - const clerkId = orgData.id - if (!clerkId) { - throw new Error('Missing Clerk ID in organization data') - } + const { id: clerkId, name, ...otherOrgData } = organization - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Try to find the existing organization first - let pbOrg: Organization | null = null - try { - pbOrg = await pb - .collection('organizations') - .getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - // Organization doesn't exist yet, we'll create a new one - pbOrg = null - console.error('Error syncing organization to PocketBase:', error) + if (!clerkId) { + throw new Error('Clerk organization ID is required for syncing') } - // Prepare the organization data + // Prepare organization data const orgDataToSync = { - address: orgData.public_metadata?.address || null, - clerkId: clerkId, - email: orgData.email_address || null, - name: orgData.name || 'Organization', - phone: orgData.phone_number || null, - settings: orgData.public_metadata?.settings || {}, + clerkId, + name: name || 'Unnamed Organization', + // Add other fields as needed } - // Update or create the organization - if (pbOrg) { - console.info(`Updating existing organization ${clerkId} in PocketBase`) - return await pb - .collection('organizations') - .update(pbOrg.id, orgDataToSync) + // Update or create + if (existingOrg) { + console.info(`Updating existing Organization ${clerkId} in PocketBase`) + return await _updateOrganization(existingOrg.id, orgDataToSync) } else { - console.info(`Creating new organization ${clerkId} in PocketBase`) - return await pb.collection('organizations').create(orgDataToSync) + console.info(`Creating new Organization ${clerkId} in PocketBase`) + return await _createOrganization(orgDataToSync) } } catch (error) { console.error('Error syncing organization to PocketBase:', error) diff --git a/src/app/actions/services/pocketbase/app-user/search.ts b/src/app/actions/services/pocketbase/app-user/search.ts index 82c198a..e9cd028 100644 --- a/src/app/actions/services/pocketbase/app-user/search.ts +++ b/src/app/actions/services/pocketbase/app-user/search.ts @@ -8,7 +8,7 @@ import { validateOrganizationAccess, PermissionLevel, SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +} from '@/app/actions/services/securyUtilsTools' import { ListOptions, ListResult, AppUser } from '@/types/types_pocketbase' /** diff --git a/src/app/actions/services/pocketbase/organization/internal.ts b/src/app/actions/services/pocketbase/organization/internal.ts index 633ed47..6bd2c60 100644 --- a/src/app/actions/services/pocketbase/organization/internal.ts +++ b/src/app/actions/services/pocketbase/organization/internal.ts @@ -25,12 +25,20 @@ export async function _createOrganization( throw new Error('Failed to connect to PocketBase') } - return await pb.collection('organizations').create(data) + console.log('Creating new Organization with data:', data) + + // Make sure clerkId is included + if (!data.clerkId) { + throw new Error('clerkId is required when creating a new Organization') + } + + const newOrg = await pb.collection('Organization').create(data) + console.log('New Organization created:', newOrg) + + return newOrg } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._createOrganization' - ) + console.error('Error creating organization:', error) + throw error } } @@ -49,12 +57,15 @@ export async function _updateOrganization( throw new Error('Failed to connect to PocketBase') } - return await pb.collection('organizations').update(id, data) + console.log(`Updating Organization ${id} with data:`, data) + + const updatedOrg = await pb.collection('Organization').update(id, data) + console.log('Organization updated:', updatedOrg) + + return updatedOrg } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._updateOrganization' - ) + console.error('Error updating organization:', error) + throw error } } @@ -82,7 +93,7 @@ export async function _deleteOrganization(id: string): Promise { * @param {string} clerkId - Clerk organization ID * @returns {Promise} Organization record or null if not found */ -export async function getByClerkId( +export async function getOrganizationByClerkId( clerkId: string ): Promise { try { @@ -91,15 +102,28 @@ export async function getByClerkId( throw new Error('Failed to connect to PocketBase') } - const organization = await pb - .collection('organizations') - .getFirstListItem(`clerkId=${clerkId}`) - return organization as Organization - } catch (error) { - // If organization not found, return null instead of throwing - if (error instanceof Error && error.message.includes('404')) { - return null + console.log(`Searching for Organization with clerkId: ${clerkId}`) + + try { + const org = await pb + .collection('Organization') + .getFirstListItem(`clerkId="${clerkId}"`) + + console.log('Organization found:', org) + return org + } catch (error) { + // Check if this is a "not found" error + if ( + error instanceof Error && + (error.message.includes('404') || error.message.includes('not found')) + ) { + console.log(`No organization found with clerkId: ${clerkId}`) + return null + } + // Otherwise rethrow the error + throw error } + } catch (error) { console.error('Error fetching organization by clerk ID:', error) return null } From ea95e7870f43621954a6064cbfcc651151243ebc Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:21:00 +0200 Subject: [PATCH 33/73] chore(cleanup): remove large text and output files - Delete large.txt and output.txt to clean up the repository - Remove unnecessary content that was no longer needed for the project --- large.txt | 29 - output.txt | 8299 ----------------- .../services/clerk-sync/syncService.ts | 54 +- 3 files changed, 24 insertions(+), 8358 deletions(-) delete mode 100644 large.txt delete mode 100644 output.txt diff --git a/large.txt b/large.txt deleted file mode 100644 index 1fad7b1..0000000 --- a/large.txt +++ /dev/null @@ -1,29 +0,0 @@ -src/types/types_pocketbase.ts -src/components/ui/stepper.tsx -src/components/ui/sidebar.tsx -src/components/ui/sheet.tsx -src/components/magicui/confetti.tsx -src/components/app/app-sidebar.tsx -src/app/globals.css -src/app/actions/services/pocketbase/userService.ts -src/app/actions/services/pocketbase/securityUtils.ts -src/app/actions/services/pocketbase/projectService.ts -src/app/actions/services/pocketbase/organizationService.ts -src/app/actions/services/pocketbase/equipmentService.ts -src/app/actions/services/pocketbase/assignmentService.ts -src/app/actions/equipment/manageEquipments.ts -src/app/(application)/app/page.tsx -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/page.tsx -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep.tsx -docs-and-prompts/technique-prompt-system.md -docs-and-prompts/stack-technique.md -docs-and-prompts/cahier-des-charges.md -docs-and-prompts/market/tunnel-conversion.md -docs-and-prompts/market/strategie-marketing-honnete.md -docs-and-prompts/market/strategie-marketing-fortooling.md -docs-and-prompts/market/pages-techniques-parcours.md -docs-and-prompts/market/pages-support-conversion.md -docs-and-prompts/market/pages-seo-sectorielles.md -docs-and-prompts/market/pages-essentielles.md -docs-and-prompts/market/contenu-landing-page.md diff --git a/output.txt b/output.txt deleted file mode 100644 index c90e9c6..0000000 --- a/output.txt +++ /dev/null @@ -1,8299 +0,0 @@ -The following text represents a project with code. The structure of the text consists of sections beginning with ----, followed by a single line containing the file path and file name, and then a variable number of lines containing the file contents. The text representing the project ends when the symbols --END-- are encountered. Any further text beyond --END-- is meant to be interpreted as instructions using the aforementioned project as context. ----- -tsconfig.json -{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} - ----- -renovate.json -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:base"], - "packageRules": [ - { - "matchUpdateTypes": ["minor", "patch"], - "matchCurrentVersion": "!/^0/", - "automerge": true, - "automergeType": "pr", - "automergeStrategy": "squash" - } - ] -} - ----- -prettier.config.js -module.exports = { - arrowParens: 'avoid', - bracketSpacing: true, - embeddedLanguageFormatting: 'auto', - endOfLine: 'auto', - htmlWhitespaceSensitivity: 'css', - insertPragma: false, - jsxSingleQuote: true, - plugins: ['prettier-plugin-tailwindcss'], - printWidth: 80, - proseWrap: 'preserve', - quoteProps: 'as-needed', - requirePragma: false, - semi: false, - singleQuote: true, - tabWidth: 2, - trailingComma: 'es5', - useTabs: true, - vueIndentScriptAndStyle: false, -} - ----- -postcss.config.mjs -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -} - -export default config - ----- -package.json -{ - "name": "for-tooling", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", - "format": "prettier --write .", - "format:check": "prettier --check .", - "tsc": "npx tsc --noEmit --watch", - "prepare": "husky install" - }, - "dependencies": { - "@clerk/nextjs": "6.12.12", - "@eslint/js": "9.23.0", - "@headlessui/react": "2.2.0", - "@heroicons/react": "2.2.0", - "@radix-ui/react-avatar": "1.1.3", - "@radix-ui/react-dialog": "1.1.6", - "@radix-ui/react-icons": "1.3.2", - "@radix-ui/react-separator": "1.1.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-tooltip": "1.1.8", - "@types/canvas-confetti": "1.9.0", - "autoprefixer": "10.4.21", - "canvas-confetti": "1.9.3", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "dayjs": "1.11.13", - "framer-motion": "12.6.2", - "heroicons": "2.2.0", - "lucide-react": "0.485.0", - "next": "15.2.4", - "pocketbase": "0.25.2", - "postcss": "8.5.3", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-use-measure": "2.1.7", - "tailwind-merge": "3.0.2", - "tw-animate-css": "1.2.5", - "zod": "3.24.2", - "zustand": "5.0.3" - }, - "devDependencies": { - "@eslint/eslintrc": "3.3.1", - "@next/eslint-plugin-next": "15.2.4", - "@tailwindcss/postcss": "4.0.17", - "@types/node": "22.13.14", - "@types/react": "19.0.12", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", - "eslint": "9.23.0", - "eslint-config-next": "15.2.4", - "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.1", - "eslint-plugin-prettier": "5.2.5", - "eslint-plugin-react": "7.37.4", - "husky": "9.1.7", - "prettier": "3.5.3", - "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "4.0.17", - "tailwindcss-animate": "1.0.7", - "typescript": "5.8.2", - "typescript-eslint": "8.28.0" - } -} - ----- -next.config.ts -import type { NextConfig } from 'next' - -const nextConfig: NextConfig = { - /* config options here */ - images: { - remotePatterns: [ - { - hostname: '**.andy-cinquin.fr', - protocol: 'https', - }, - { - hostname: '**.clerk.com', - protocol: 'https', - }, - ], - }, -} - -export default nextConfig - ----- -large.txt -src/types/types_pocketbase.ts -src/components/ui/stepper.tsx -src/components/ui/sidebar.tsx -src/components/ui/sheet.tsx -src/components/magicui/confetti.tsx -src/components/app/app-sidebar.tsx -src/app/globals.css -src/app/actions/services/pocketbase/userService.ts -src/app/actions/services/pocketbase/securityUtils.ts -src/app/actions/services/pocketbase/projectService.ts -src/app/actions/services/pocketbase/organizationService.ts -src/app/actions/services/pocketbase/equipmentService.ts -src/app/actions/services/pocketbase/assignmentService.ts -src/app/actions/equipment/manageEquipments.ts -src/app/(application)/app/page.tsx -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/page.tsx -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep.tsx -docs-and-prompts/technique-prompt-system.md -docs-and-prompts/stack-technique.md -docs-and-prompts/cahier-des-charges.md -docs-and-prompts/market/tunnel-conversion.md -docs-and-prompts/market/strategie-marketing-honnete.md -docs-and-prompts/market/strategie-marketing-fortooling.md -docs-and-prompts/market/pages-techniques-parcours.md -docs-and-prompts/market/pages-support-conversion.md -docs-and-prompts/market/pages-seo-sectorielles.md -docs-and-prompts/market/pages-essentielles.md -docs-and-prompts/market/contenu-landing-page.md - ----- -eslint.config.mjs -import { FlatCompat } from '@eslint/eslintrc' -import js from '@eslint/js' -import perfectionist from 'eslint-plugin-perfectionist' -import { defineConfig } from 'eslint/config' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const compat = new FlatCompat({ - allConfig: js.configs.all, - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, -}) - -export default defineConfig([ - { - extends: compat.extends('next/core-web-vitals', 'next/typescript'), - - plugins: { - perfectionist, - }, - - rules: { - 'no-console': [ - 'error', - { - allow: ['warn', 'error', 'info'], - }, - ], - - 'perfectionist/sort-enums': 'error', - 'perfectionist/sort-imports': ['error'], - 'perfectionist/sort-objects': 'error', - 'perfectionist/sort-variable-declarations': 'error', - }, - - settings: { - perfectionist: { - partitionByComment: false, - partitionByNewLine: false, - type: 'alphabetical', - }, - }, - }, -]) - ----- -components.json -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} - ----- -README.md -# Fortooling - --- -api : - -- https://api.fortooling.forhives.fr/_/ -- See our vaultwarden for password and credentials - ----- -LICENSE -MIT License - -Copyright (c) 2025 ForHives - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ----- -src/middleware.ts -import { - clerkClient, - clerkMiddleware, - createRouteMatcher, -} from '@clerk/nextjs/server' -const isProtectedRoute = createRouteMatcher(['/app(.*)']) - -const isPublicRoute = createRouteMatcher([ - '/', - '/app(.*)', - '/pricing(.*)', - '/legals(.*)', - '/marketing-components(.*)', - '/sign-in(.*)', - '/sign-up(.*)', - '/api(.*)', - '/invitation(.*)', - '/onboarding(.*)', - '/create-organization(.*)', - '/waitlist(.*)', -]) - -const isAdminRoute = createRouteMatcher(['/admin(.*)']) - -export default clerkMiddleware(async (auth, req) => { - if (isPublicRoute(req)) { - return - } - - const authAwaited = await auth() - if (!authAwaited.userId) { - return Response.redirect(new URL('/sign-in', req.url)) - } - - if (isAdminRoute(req)) { - const userData = authAwaited.orgRole - if (userData !== 'admin') { - return Response.redirect(new URL('/', req.url)) - } - } - - if (!authAwaited.orgId) { - return Response.redirect(new URL('/onboarding', req.url)) - } - - const clerkClientInstance = await clerkClient() - const userMetadata = await clerkClientInstance.users.getUser( - authAwaited.userId - ) - - if ( - isProtectedRoute(req) && - !userMetadata?.publicMetadata?.hasCompletedOnboarding - ) { - return Response.redirect(new URL('/onboarding', req.url)) - } - - if (isProtectedRoute(req)) await auth.protect() -}) - -export const config = { - matcher: [ - // Skip Next.js internals and all static files, unless found in search params - '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', - // Always run for API routes - '/(api|trpc)(.*)', - ], -} - ----- -src/types/types_pocketbase.ts -/** - * Common fields for all record models - */ -export interface BaseModel { - id: string - created: string - updated: string - collectionId: string - collectionName: string -} - -/** - * Organization model - */ -export interface Organization extends BaseModel { - name: string - email: string | null - phone: string | null - address: string | null - settings: Record | null - - // Clerk integration fields - clerkId: string - - // Stripe related fields - stripeCustomerId?: string - subscriptionId?: string - subscriptionStatus?: string - priceId?: string - - // Expanded relations - expand?: Record -} - -/** - * User model (auth collection) - */ -export interface User extends BaseModel { - email: string - emailVisibility: boolean - verified: boolean - name: string | null - avatar?: string | null // File field - phone: string | null - role: string | null - isAdmin: boolean - canLogin: boolean - lastLogin?: string - - // Clerk integration field - clerkId?: string - - // Expanded relations - // the user can be part of multiple organizations - expand?: { - organizationId?: Organization[] - } -} - -/** - * Equipment model - */ -export interface Equipment extends BaseModel { - organizationId: string // References Organization.id - name: string | null - qrNfcCode: string | null - tags: string[] - notes: string | null - acquisitionDate: string | null // ISO date string - parentEquipmentId: string | null // Self-reference - - // Expanded relations - expand?: { - organizationId?: Organization - parentEquipmentId?: Equipment - } -} - -/** - * Project model - */ -export interface Project extends BaseModel { - name: string | null - address: string | null - notes: string | null - startDate: string | null // ISO date string - endDate: string | null // ISO date string - organizationId: string // References Organization.id - - // Expanded relations - expand?: { - organizationId?: Organization - } -} - -/** - * Assignment model - */ -export interface Assignment extends BaseModel { - organizationId: string // References Organization.id - equipmentId: string // References Equipment.id - assignedToUserId: string | null // References User.id - assignedToProjectId: string | null // References Project.id - startDate: string | null // ISO date string - endDate: string | null // ISO date string - notes: string | null - - // Expanded relations - expand?: { - organizationId?: Organization - equipmentId?: Equipment - assignedToUserId?: User - assignedToProjectId?: Project - } -} - -/** - * Images model (this will be used to store images for the blog etc) - */ -export interface Image extends BaseModel { - title: string | null - alt: string | null - caption: string | null - image: string | null - - // Expanded relations - expand?: Record -} - -/** - * Filter options for list operations - */ -export interface ListOptions { - filter?: string - sort?: string - expand?: string - fields?: string - skipTotal?: boolean - page?: number - perPage?: number - requestKey?: string | null -} - -/** - * Common result format for paginated lists - */ -export interface ListResult { - page: number - perPage: number - totalItems: number - totalPages: number - items: T[] -} - ----- -src/stores/onboarding-store.ts -import { create } from 'zustand' -import { persist } from 'zustand/middleware' - -export type OnboardingStep = 1 | 2 | 3 | 4 | 5 - -interface OnboardingState { - currentStep: OnboardingStep - setCurrentStep: (step: OnboardingStep) => void - isLoading: boolean - setIsLoading: (loading: boolean) => void - resetOnboarding: () => void -} - -export const useOnboardingStore = create()( - persist( - set => ({ - currentStep: 1, - isLoading: false, - resetOnboarding: () => set({ currentStep: 1, isLoading: false }), - setCurrentStep: step => set({ currentStep: step }), - setIsLoading: loading => set({ isLoading: loading }), - }), - { - name: 'onboarding-state', - } - ) -) - ----- -src/lib/utils.ts -import { clsx, type ClassValue } from 'clsx' -import { twMerge } from 'tailwind-merge' - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} - ----- -src/lib/tagsUtils.ts -/** - * Convert tags array to string format for PocketBase storage - * @param tags Array of tag strings - * @returns JSON string representation or null - */ -export function tagsToStorage(tags?: string[]): string | null { - if (!tags || tags.length === 0) return null - return JSON.stringify(tags) -} - -/** - * Convert tags from PocketBase storage format to array for UI - * @param tagsString JSON string or null from PocketBase - * @returns Array of tag strings - */ -export function tagsFromStorage(tagsString: string | null): string[] { - if (!tagsString) return [] - - try { - const parsed = JSON.parse(tagsString) - if (Array.isArray(parsed)) { - return parsed - } - // Handle case where it might be a comma-separated string - if (typeof parsed === 'string') { - return parsed - .split(',') - .map(tag => tag.trim()) - .filter(Boolean) - } - return [] - } catch (error) { - // If JSON parsing fails, try treating it as a comma-separated string - if (typeof tagsString === 'string') { - return tagsString - .split(',') - .map(tag => tag.trim()) - .filter(Boolean) - } - return [] - } -} - ----- -src/hooks/use-mobile.ts -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile -} - ----- -src/components/organization-sync.tsx -// src/components/organization-sync.tsx -'use client' -import { useOrganization } from '@clerk/nextjs' -import { useRouter, useParams } from 'next/navigation' -import { useEffect } from 'react' - -export function OrganizationSync() { - const { organization } = useOrganization() - const params = useParams() - const router = useRouter() - const orgId = params?.orgId as string | undefined - - useEffect(() => { - // If there's an active organization and it doesn't match the URL, update the URL - if (organization && orgId && organization.id !== orgId) { - router.replace(`/org/${organization.id}`) - } - - // If there's no active organization but we have an orgId in the URL, set it as active - if (!organization && orgId) { - // This would require additional logic to set the active organization - } - }, [organization, orgId, router]) - - return null -} - ----- -src/components/ui/tooltip.tsx -'use client' - -import { cn } from '@/lib/utils' -import * as TooltipPrimitive from '@radix-ui/react-tooltip' -import * as React from 'react' - -function TooltipProvider({ - delayDuration = 0, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function Tooltip({ - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function TooltipContent({ - children, - className, - sideOffset = 0, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - ) -} - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } - ----- -src/components/ui/stepper.tsx -'use client' - -import { cn } from '@/lib/utils' -import { CheckIcon } from '@radix-ui/react-icons' -import { LoaderCircle } from 'lucide-react' -import * as React from 'react' -import { createContext, useContext } from 'react' - -// Types -type StepperContextValue = { - activeStep: number - setActiveStep: (step: number) => void - orientation: 'horizontal' | 'vertical' -} - -type StepItemContextValue = { - step: number - state: StepState - isDisabled: boolean - isLoading: boolean -} - -type StepState = 'active' | 'completed' | 'inactive' | 'loading' - -// Contexts -const StepperContext = createContext(undefined) -const StepItemContext = createContext( - undefined -) - -const useStepper = () => { - const context = useContext(StepperContext) - if (!context) { - throw new Error('useStepper must be used within a Stepper') - } - return context -} - -const useStepItem = () => { - const context = useContext(StepItemContext) - if (!context) { - throw new Error('useStepItem must be used within a StepperItem') - } - return context -} - -// Components -interface StepperProps extends React.HTMLAttributes { - defaultValue?: number - value?: number - onValueChange?: (value: number) => void - orientation?: 'horizontal' | 'vertical' -} - -const Stepper = React.forwardRef( - ( - { - className, - defaultValue = 0, - onValueChange, - orientation = 'horizontal', - value, - ...props - }, - ref - ) => { - const [activeStep, setInternalStep] = React.useState(defaultValue) - - const setActiveStep = React.useCallback( - (step: number) => { - if (value === undefined) { - setInternalStep(step) - } - onValueChange?.(step) - }, - [value, onValueChange] - ) - - const currentStep = value ?? activeStep - - return ( - -
- - ) - } -) -Stepper.displayName = 'Stepper' - -// StepperItem -interface StepperItemProps extends React.HTMLAttributes { - step: number - completed?: boolean - disabled?: boolean - loading?: boolean -} - -const StepperItem = React.forwardRef( - ( - { - children, - className, - completed = false, - disabled = false, - loading = false, - step, - ...props - }, - ref - ) => { - const { activeStep } = useStepper() - - const state: StepState = - completed || step < activeStep - ? 'completed' - : activeStep === step - ? 'active' - : 'inactive' - - const isLoading = loading && step === activeStep - - return ( - -
- {children} -
-
- ) - } -) -StepperItem.displayName = 'StepperItem' - -// StepperTrigger -interface StepperTriggerProps - extends React.ButtonHTMLAttributes { - asChild?: boolean -} - -const StepperTrigger = React.forwardRef( - ({ asChild = false, children, className, ...props }, ref) => { - const { setActiveStep } = useStepper() - const { isDisabled, step } = useStepItem() - - if (asChild) { - return
{children}
- } - - return ( - - ) - } -) -StepperTrigger.displayName = 'StepperTrigger' - -// StepperIndicator -interface StepperIndicatorProps extends React.HTMLAttributes { - asChild?: boolean -} - -const StepperIndicator = React.forwardRef< - HTMLDivElement, - StepperIndicatorProps ->(({ asChild = false, children, className, ...props }, ref) => { - const { isLoading, state, step } = useStepItem() - - return ( -
- {asChild ? ( - children - ) : ( - <> - - {step} - -
- ) -}) -StepperIndicator.displayName = 'StepperIndicator' - -// StepperTitle -const StepperTitle = React.forwardRef< - HTMLHeadingElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)) -StepperTitle.displayName = 'StepperTitle' - -// StepperDescription -const StepperDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)) -StepperDescription.displayName = 'StepperDescription' - -// StepperSeparator -const StepperSeparator = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - return ( -

- ) -}) -StepperSeparator.displayName = 'StepperSeparator' - -export { - Stepper, - StepperDescription, - StepperIndicator, - StepperItem, - StepperSeparator, - StepperTitle, - StepperTrigger, -} - ----- -src/components/ui/spotlight-card.tsx -'use client' -import React, { useRef, useState } from 'react' - -interface Position { - x: number - y: number -} - -interface SpotlightCardProps extends React.PropsWithChildren { - className?: string - spotlightColor?: `rgba(${number}, ${number}, ${number}, ${number})` -} - -const SpotlightCard: React.FC = ({ - children, - className = '', - spotlightColor = 'rgba(255, 255, 255, 0.25)', -}) => { - const divRef = useRef(null) - const [isFocused, setIsFocused] = useState(false) - const [position, setPosition] = useState({ x: 0, y: 0 }) - const [opacity, setOpacity] = useState(0) - - const handleMouseMove: React.MouseEventHandler = e => { - if (!divRef.current || isFocused) return - - const rect = divRef.current.getBoundingClientRect() - setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }) - } - - const handleFocus = () => { - setIsFocused(true) - setOpacity(0.6) - } - - const handleBlur = () => { - setIsFocused(false) - setOpacity(0) - } - - const handleMouseEnter = () => { - setOpacity(0.6) - } - - const handleMouseLeave = () => { - setOpacity(0) - } - - return ( -
-
- {children} -
- ) -} - -export default SpotlightCard - ----- -src/components/ui/skeleton.tsx -import { cn } from '@/lib/utils' - -function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ) -} - -export { Skeleton } - ----- -src/components/ui/sidebar.tsx -'use client' - -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Separator } from '@/components/ui/separator' -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet' -import { Skeleton } from '@/components/ui/skeleton' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { useIsMobile } from '@/hooks/use-mobile' -import { cn } from '@/lib/utils' -import { Slot } from '@radix-ui/react-slot' -import { VariantProps, cva } from 'class-variance-authority' -import { PanelLeftIcon } from 'lucide-react' -import * as React from 'react' - -const SIDEBAR_COOKIE_NAME = 'sidebar_state' -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = '16rem' -const SIDEBAR_WIDTH_MOBILE = '18rem' -const SIDEBAR_WIDTH_ICON = '3rem' -const SIDEBAR_KEYBOARD_SHORTCUT = 'b' - -type SidebarContextProps = { - state: 'expanded' | 'collapsed' - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} - -const SidebarContext = React.createContext(null) - -function useSidebar() { - const context = React.useContext(SidebarContext) - if (!context) { - throw new Error('useSidebar must be used within a SidebarProvider.') - } - - return context -} - -function SidebarProvider({ - children, - className, - defaultOpen = true, - onOpenChange: setOpenProp, - open: openProp, - style, - ...props -}: React.ComponentProps<'div'> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void -}) { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === 'function' ? value(open) : value - if (setOpenProp) { - setOpenProp(openState) - } else { - _setOpen(openState) - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` - }, - [setOpenProp, open] - ) - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open) - }, [isMobile, setOpen, setOpenMobile]) - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault() - toggleSidebar() - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [toggleSidebar]) - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? 'expanded' : 'collapsed' - - const contextValue = React.useMemo( - () => ({ - isMobile, - open, - openMobile, - setOpen, - setOpenMobile, - state, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ) - - return ( - - -
- {children} -
-
-
- ) -} - -function Sidebar({ - children, - className, - collapsible = 'offcanvas', - side = 'left', - variant = 'sidebar', - ...props -}: React.ComponentProps<'div'> & { - side?: 'left' | 'right' - variant?: 'sidebar' | 'floating' | 'inset' - collapsible?: 'offcanvas' | 'icon' | 'none' -}) { - const { isMobile, openMobile, setOpenMobile, state } = useSidebar() - - if (collapsible === 'none') { - return ( -
- {children} -
- ) - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ) -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps) { - const { toggleSidebar } = useSidebar() - - return ( - - ) -} - -function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { - const { toggleSidebar } = useSidebar() - - return ( - - ) -} - -ConfettiButtonComponent.displayName = 'ConfettiButton' - -export const ConfettiButton = ConfettiButtonComponent - ----- -src/components/app/top-bar.tsx -'use client' -import { SignedIn } from '@clerk/nextjs' -import { Search } from 'lucide-react' -import { usePathname } from 'next/navigation' - -export function TopBar() { - const pathname = usePathname() - - // Function to generate page title based on pathname - const getPageTitle = () => { - if (pathname === '/app') return 'Dashboard' - - const paths = pathname?.split('/').filter(Boolean) || [] - if (paths.length === 0) return 'Dashboard' - - const lastPath = paths[paths.length - 1] - return lastPath.charAt(0).toUpperCase() + lastPath.slice(1) - } - - const pageTitle = getPageTitle() - - return ( -
-
-
-

{pageTitle}

-
-
-
- - - - -
-
- ) -} - ----- -src/components/app/container.tsx -import { clsx } from 'clsx' - -export function Container({ - children, - isAlternative = false, -}: { - children: React.ReactNode - isAlternative?: boolean -}) { - return ( -
-
- {children} -
-
- ) -} - ----- -src/components/app/app-sidebar.tsx -'use client' -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, -} from '@/components/ui/sidebar' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { - RedirectToSignIn, - SignedIn, - SignedOut, - UserButton, -} from '@clerk/nextjs' -import { - Construction, - Wrench, - User, - HardHat, - Scan, - ClipboardList, - Building, -} from 'lucide-react' -import Link from 'next/link' -import { usePathname } from 'next/navigation' - -export function AppSidebar() { - const pathname = usePathname() - - return ( - -
- - - -
- -
- -
- Accueil -
-
- - - - - - - - - - - - - Équipements - - - - - - - - - - - - Projets - - - - - - - - - - - - Utilisateurs - - - - - - - - - - - - Scanner - - - - - - - - - - - - Inventaire - - - - - - - - - - - - - - - - - - Organisation - - - - - - -
- -
- -
-
- - - -
-
-
- Profil -
-
-
-
-
-
- ) -} - ----- -src/app/globals.css -@import 'tailwindcss'; - -@custom-variant dark (&:is(.dark *)); - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.129 0.042 264.695); - --card: oklch(1 0 0); - --card-foreground: oklch(0.129 0.042 264.695); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.129 0.042 264.695); - --primary: oklch(0.208 0.042 265.755); - --primary-foreground: oklch(0.984 0.003 247.858); - --secondary: oklch(0.968 0.007 247.896); - --secondary-foreground: oklch(0.208 0.042 265.755); - --muted: oklch(0.968 0.007 247.896); - --muted-foreground: oklch(0.554 0.046 257.417); - --accent: oklch(0.968 0.007 247.896); - --accent-foreground: oklch(0.208 0.042 265.755); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.929 0.013 255.508); - --input: oklch(0.929 0.013 255.508); - --ring: oklch(0.704 0.04 256.788); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.984 0.003 247.858); - --sidebar-foreground: oklch(0.129 0.042 264.695); - --sidebar-primary: oklch(0.208 0.042 265.755); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.968 0.007 247.896); - --sidebar-accent-foreground: oklch(0.208 0.042 265.755); - --sidebar-border: oklch(0.929 0.013 255.508); - --sidebar-ring: oklch(0.704 0.04 256.788); -} - -.dark { - --background: oklch(0.129 0.042 264.695); - --foreground: oklch(0.984 0.003 247.858); - --card: oklch(0.208 0.042 265.755); - --card-foreground: oklch(0.984 0.003 247.858); - --popover: oklch(0.208 0.042 265.755); - --popover-foreground: oklch(0.984 0.003 247.858); - --primary: oklch(0.929 0.013 255.508); - --primary-foreground: oklch(0.208 0.042 265.755); - --secondary: oklch(0.279 0.041 260.031); - --secondary-foreground: oklch(0.984 0.003 247.858); - --muted: oklch(0.279 0.041 260.031); - --muted-foreground: oklch(0.704 0.04 256.788); - --accent: oklch(0.279 0.041 260.031); - --accent-foreground: oklch(0.984 0.003 247.858); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.551 0.027 264.364); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.208 0.042 265.755); - --sidebar-foreground: oklch(0.984 0.003 247.858); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.279 0.041 260.031); - --sidebar-accent-foreground: oklch(0.984 0.003 247.858); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.551 0.027 264.364); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } -} - -@keyframes move-x { - 0% { - transform: translateX(var(--move-x-from)); - } - 100% { - transform: translateX(var(--move-x-to)); - } -} - ----- -src/app/actions/services/pocketbase/userService.ts -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, - ResourceType, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { ListOptions, ListResult, User } from '@/types/types_pocketbase' - -/** - * Get a single user by ID with security validation - */ -export async function getUser(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'UserService.getUser') - } -} - -/** - * Get current authenticated user profile - */ -export async function getCurrentUser(): Promise { - try { - // This function automatically validates the current user - return await validateCurrentUser() - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getCurrentUser') - } -} - -/** - * Get a user by Clerk ID - typically used during authentication - */ -export async function getUserByClerkId(clerkId: string): Promise { - // This is primarily used during authentication flows where - // standard security checks aren't possible yet. - // However, requests should still come from server-side code only. - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUserByClerkId') - } -} - -/** - * Get users list with pagination and security checks - */ -export async function getUsersList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - needs at least READ permission - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) - - return await pb.collection('users').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUsersList') - } -} - -/** - * Get all users for an organization with security checks - */ -export async function getUsersByOrganization( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter with the correct field name - // Since users can belong to multiple organizations, we need to check expand.organizationId - return await pb.collection('users').getFullList({ - filter: `organizationId.organizationId="${organizationId}"`, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUsersByOrganization') - } -} - -/** - * Create a new user with security checks - * This is typically controlled access for admins only - */ -export async function createUser( - organizationId: string, - data: Pick< - Partial, - | 'name' - | 'email' - | 'emailVisibility' - | 'verified' - | 'avatar' - | 'phone' - | 'role' - | 'isAdmin' - | 'canLogin' - | 'clerkId' - > -): Promise { - try { - // Security check - requires ADMIN permission to create users - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set correctly with the proper field name - return await pb.collection('users').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.createUser') - } -} - -/** - * Update a user with security checks - */ -export async function updateUser( - id: string, - data: Pick< - Partial, - | 'name' - | 'email' - | 'emailVisibility' - | 'verified' - | 'avatar' - | 'phone' - | 'role' - | 'isAdmin' - | 'canLogin' - | 'lastLogin' - | 'clerkId' - > -): Promise { - try { - // Get current authenticated user - const currentUser = await validateCurrentUser() - - // Different permission checks based on who is being updated - if (id !== currentUser.id) { - // Updating someone else requires ADMIN permission - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) - } else { - // Users can update their own basic info - // But for role changes, they'd still need admin rights - if (data.role || data.isAdmin !== undefined) { - // If trying to change role or admin status, require admin permission - // Get the user's organization ID - handling possible multiple organizations - const userOrgs = currentUser.expand?.organizationId - - if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { - throw new SecurityError('User does not belong to any organization') - } - - // Use the first organization for permission check - const primaryOrgId = userOrgs[0].id - await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) - } - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Security: never allow changing certain fields - const sanitizedData = { ...data } - // Don't allow org changes or clerk ID changes - use proper type assertion - delete (sanitizedData as Record).organizationId - if (sanitizedData['clerkId']) { - delete sanitizedData.clerkId - } - - return await pb.collection('users').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.updateUser') - } -} - -/** - * Delete a user with admin permission check - */ -export async function deleteUser(id: string): Promise { - try { - // Security check - requires ADMIN permission for user deletion - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('users').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.deleteUser') - } -} - -/** - * Update user's last login time - * This is typically called during authentication flows - */ -export async function updateUserLastLogin(id: string): Promise { - try { - // Since this is called during authentication, - // we'll just verify the user exists rather than permissions - const user = await validateCurrentUser(id) - - if (!user) { - throw new SecurityError('User not found') - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').update(id, { - lastLogin: new Date().toISOString(), - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.updateUserLastLogin') - } -} - -/** - * Get the count of users in an organization - */ -export async function getUserCount(organizationId: string): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name in the filter - const result = await pb.collection('users').getList(1, 1, { - filter: `organizationId.organizationId=${organizationId}`, - skipTotal: false, - }) - - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUserCount') - } -} - -/** - * Search for users in the organization - */ -export async function searchUsers( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name in the filter and handle multi-organization relationship - return await pb.collection('users').getFullList({ - filter: pb.filter( - 'organizationId.organizationId = {:orgId} && (name ~ {:query} || email ~ {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.searchUsers') - } -} - ----- -src/app/actions/services/pocketbase/securityUtils.ts -'use server' - -import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' -import { User } from '@/types/types_pocketbase' -import { auth } from '@clerk/nextjs/server' - -/** - * User permission levels - */ -export enum PermissionLevel { - ADMIN = 'admin', - READ = 'read', - WRITE = 'write', -} - -/** - * Resource types for permission checks - */ -export enum ResourceType { - ASSIGNMENT = 'assignment', - EQUIPMENT = 'equipment', - ORGANIZATION = 'organization', - PROJECT = 'project', - USER = 'user', -} - -/** - * Error thrown when security checks fail - */ -export class SecurityError extends Error { - constructor(message: string) { - super(message) - this.name = 'SecurityError' - } -} - -/** - * Validates a user ID against the current authenticated user - * @param userId The user ID to validate - * @throws {SecurityError} If the user ID is invalid or unauthorized - */ -export async function validateCurrentUser(userId?: string): Promise { - // Get Clerk auth context - const { userId: clerkUserId } = await auth() - - if (!clerkUserId) { - throw new SecurityError('Unauthenticated access') - } - - const pb = await getPocketBase() - if (!pb) { - throw new SecurityError('Database connection error') - } - - try { - // Find the user by Clerk ID - const user = await pb - .collection('users') - .getFirstListItem(`clerkId=${clerkUserId}`) - - // If a specific user ID was provided, verify it matches the current user - if (userId && user.id !== userId) { - throw new SecurityError('Unauthorized access to user data') - } - - return user - } catch (error) { - console.error('User validation error:', error) - throw new SecurityError('Failed to validate user') - } -} - -/** - * Validates organizational access and permissions - * @param organizationId The organization ID to validate - * @param permission The required permission level - * @returns The validated user and organization - * @throws {SecurityError} If access is unauthorized - */ -export async function validateOrganizationAccess( - organizationId: string, - permission: PermissionLevel = PermissionLevel.READ -): Promise<{ user: User; organizationId: string }> { - // Get authenticated user - const user = await validateCurrentUser() - - // Check organization membership - if (user.expand?.organizationId !== organizationId) { - throw new SecurityError('Unauthorized access to organization data') - } - - // Check permission level - if ( - permission === PermissionLevel.ADMIN && - !user.isAdmin && - user.role !== 'admin' - ) { - throw new SecurityError('Insufficient permissions for this operation') - } - - if ( - permission === PermissionLevel.WRITE && - !user.isAdmin && - user.role !== 'admin' && - user.role !== 'manager' - ) { - throw new SecurityError('Insufficient permissions for this operation') - } - - return { organizationId, user } -} - -/** - * Validates resource access (equipment, project, assignment) - * @param resourceType The type of resource - * @param resourceId The resource ID - * @param permission The required permission level - * @returns The validated user and organization ID - * @throws {SecurityError} If access is unauthorized - */ -export async function validateResourceAccess( - resourceType: ResourceType, - resourceId: string, - permission: PermissionLevel = PermissionLevel.READ -): Promise<{ user: User; organizationId: string }> { - const pb = await getPocketBase() - if (!pb) { - throw new SecurityError('Database connection error') - } - - try { - // Fetch the resource to check organization membership - const resource = await pb.collection(resourceType).getOne(resourceId) - - // Now validate organization access with the required permission - return validateOrganizationAccess(resource.organization, permission) - } catch (error) { - console.error( - `Resource validation error (${resourceType}/${resourceId}):`, - error - ) - throw new SecurityError('Failed to validate resource access') - } -} - -/** - * Creates a secure organization filter - * Ensures that all queries include organization-level filtering - * @param organizationId The organization ID to filter by - * @param additionalFilter Optional additional filter expression - * @returns A complete filter string with organization filtering - */ -export function createOrganizationFilter( - organizationId: string, - additionalFilter?: string -): string { - const orgFilter = `organization="${organizationId}"` - - if (!additionalFilter) { - return orgFilter - } - - return `${orgFilter} && (${additionalFilter})` -} - ----- -src/app/actions/services/pocketbase/projectService.ts -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, - ResourceType, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' - -/** - * Get a single project by ID with security validation - */ -export async function getProject(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.PROJECT, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('projects').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'ProjectService.getProject') - } -} - -/** - * Get projects list with pagination and security checks - */ -export async function getProjectsList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) - - return await pb.collection('projects').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.getProjectsList') - } -} - -/** - * Get all projects for an organization with security checks - */ -export async function getOrganizationProjects( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter - fixed field name - const filter = `organizationId="${organizationId}"` - - return await pb.collection('projects').getFullList({ - filter, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'ProjectService.getOrganizationProjects' - ) - } -} - -/** - * Get active projects with security checks - * (current date is between startDate and endDate or endDate is not set) - */ -export async function getActiveProjects( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - - // Fixed field name in filter - return await pb.collection('projects').getFullList({ - filter: pb.filter( - 'organizationId = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', - { now, orgId: organizationId } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.getActiveProjects') - } -} - -/** - * Create a new project with security checks - */ -export async function createProject( - organizationId: string, - data: Pick< - Partial, - 'name' | 'address' | 'notes' | 'startDate' | 'endDate' - > -): Promise { - try { - // Security check - requires WRITE permission - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set correctly - fixed field name - return await pb.collection('projects').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.createProject') - } -} - -/** - * Update a project with security checks - */ -export async function updateProject( - id: string, - data: Pick< - Partial, - 'name' | 'address' | 'notes' | 'startDate' | 'endDate' - > -): Promise { - try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.PROJECT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Never allow changing the organization - const sanitizedData = { ...data } - // Fixed 'any' type and field name - delete (sanitizedData as Record).organizationId - - return await pb.collection('projects').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.updateProject') - } -} - -/** - * Delete a project with security checks - */ -export async function deleteProject(id: string): Promise { - try { - // Security check - requires ADMIN permission for deletion - await validateResourceAccess( - ResourceType.PROJECT, - id, - PermissionLevel.ADMIN - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('projects').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.deleteProject') - } -} - -/** - * Get project count for an organization with security checks - */ -export async function getProjectCount(organizationId: string): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name - const result = await pb.collection('projects').getList(1, 1, { - filter: `organizationId=${organizationId}`, - skipTotal: false, - }) - - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.getProjectCount') - } -} - -/** - * Search projects by name or address with security checks - */ -export async function searchProjects( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name in filter - return await pb.collection('projects').getFullList({ - filter: pb.filter( - 'organizationId = {:orgId} && (name ~ {:query} || address ~ {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.searchProjects') - } -} - ----- -src/app/actions/services/pocketbase/organizationService.ts -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - validateOrganizationAccess, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' - -/** - * Get a single organization by ID with security validation - */ -export async function getOrganization(id: string): Promise { - try { - // Security check - validates user has access to this organization - await validateOrganizationAccess(id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('organizations').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'OrganizationService.getOrganization') - } -} - -/** - * Get an organization by Clerk ID with security validation - * This is primarily used during authentication - */ -export async function getOrganizationByClerkId( - clerkId: string -): Promise { - try { - // This endpoint is typically called during authentication - // We still validate the current user is authenticated - const user = await validateCurrentUser() - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed the template literal syntax - const organization = await pb - .collection('organizations') - .getFirstListItem(`clerkId=${clerkId}`) - - // After fetching, verify that the user belongs to this organization - // The user can have multiple organizations, so we need to check if the requested org - // is in their list of organizations - if ( - !user.expand?.organizationId || - !Array.isArray(user.expand.organizationId) - ) { - throw new SecurityError('User has no associated organizations') - } - - // Check if the requested organization is in the user's list of organizations - const hasAccess = user.expand.organizationId.some( - org => org.id === organization.id - ) - - if (!hasAccess) { - throw new SecurityError('User does not belong to this organization') - } - - // todo: fix type - return organization - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationByClerkId' - ) - } -} - -/** - * Get organizations list with pagination for the current user - */ -export async function getUserOrganizations(): Promise { - try { - const user = await validateCurrentUser() - - // If the user's organizations are already expanded, return them - if ( - user.expand?.organizationId && - Array.isArray(user.expand.organizationId) - ) { - return user.expand.organizationId - } - - // Otherwise, we need to fetch them - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Assuming there's a relation field in the users collection that points to organizations - // Fetch the user with expanded organizations - const userWithOrgs = await pb.collection('users').getOne(user.id, { - expand: 'organizationId', - }) - - if ( - userWithOrgs.expand?.organizationId && - Array.isArray(userWithOrgs.expand.organizationId) - ) { - return userWithOrgs.expand.organizationId - } - - return [] - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getUserOrganizations' - ) - } -} - -/** - * Get organizations list with pagination - * This should only be accessible to super-admins, so we don't implement it - * in a regular multi-tenant app - */ -export async function getOrganizationsList( - options: ListOptions = {} -): Promise> { - // This function should be restricted to super-admins only - throw new SecurityError( - 'This operation is restricted to super administrators' - ) -} - -/** - * Create a new organization - * This should only be done during onboarding or by super-admins - */ -export async function createOrganization( - data: Partial -): Promise { - // For creating organizations, we typically handle this specially - // during onboarding with Clerk. This should not be exposed to regular users. - throw new SecurityError('This operation is restricted') -} - -/** - * Update an organization with security validation - */ -export async function updateOrganization( - id: string, - data: Partial -): Promise { - try { - // Security check - requires ADMIN permission for organization updates - await validateOrganizationAccess(id, PermissionLevel.ADMIN) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Sanitize sensitive fields - const sanitizedData = { ...data } - - // Never allow changing the clerkId - that's a special binding - delete sanitizedData.clerkId - - // Don't allow changing Stripe-related fields directly - // These should only be updated by the Stripe webhook - delete sanitizedData.stripeCustomerId - delete sanitizedData.subscriptionId - delete sanitizedData.subscriptionStatus - delete sanitizedData.priceId - - return await pb.collection('organizations').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.updateOrganization' - ) - } -} - -/** - * Delete an organization - * This should only be accessible to super-admins or during account cancellation flows - */ -export async function deleteOrganization(id: string): Promise { - // This function should be restricted to super-admins only - // or be part of a special account cancellation flow - throw new SecurityError('This operation is restricted') -} - -/** - * Update organization subscription details - * This should only be called from Stripe webhooks, not directly by users - */ -export async function updateSubscription( - id: string, - subscriptionData: { - stripeCustomerId?: string - subscriptionId?: string - subscriptionStatus?: string - priceId?: string - } -): Promise { - try { - // This function should verify it's being called from a valid webhook - // For demo purposes, we'll implement a basic check - // In production, you'd add a webhook secret validation - - // We'll skip full security checks since this is called from webhooks - // but we still validate the organization exists - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Verify the organization exists - const organization = await pb.collection('organizations').getOne(id) - if (!organization) { - throw new Error('Organization not found') - } - - return await pb.collection('organizations').update(id, subscriptionData) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.updateSubscription' - ) - } -} - -/** - * Get current organization settings for the authenticated user - * If the user belongs to multiple organizations, takes the first active one or prompts selection - */ -export async function getCurrentOrganizationSettings(): Promise { - try { - // Get all organizations for the current user - const userOrganizations = await getUserOrganizations() - - if (!userOrganizations.length) { - throw new SecurityError('User does not belong to any organization') - } - - // For simplicity, we're returning the first organization - // In a real application, you might want to use the last selected org or prompt for selection - const firstOrgId = userOrganizations[0].id - - // Fetch full organization details with validated access - return await getOrganization(firstOrgId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getCurrentOrganizationSettings' - ) - } -} - -/** - * Check if current user is organization admin for a specific organization - */ -export async function isCurrentUserOrgAdmin( - organizationId: string -): Promise { - try { - // Get current user - const user = await validateCurrentUser() - - // Check if user has admin role - const isAdmin = user.isAdmin || user.role === 'admin' - - // If they're not an admin by role, we need to check if they're an admin of this specific org - if (!isAdmin) { - // This would need additional checks in a real application - // For example, checking a userOrganizationRole table - return false - } - - // Verify they belong to this organization - const userOrgs = await getUserOrganizations() - const belongsToOrg = userOrgs.some(org => org.id === organizationId) - - return isAdmin && belongsToOrg - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return false - } -} - ----- -src/app/actions/services/pocketbase/equipmentService.ts -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, - ResourceType, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { Equipment, ListOptions, ListResult } from '@/types/types_pocketbase' - -/** - * Get a single equipment item by ID with security validation - */ -export async function getEquipment(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess( - ResourceType.EQUIPMENT, - id, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('equipment').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'EquipmentService.getEquipment') - } -} - -/** - * Get equipment by QR/NFC code with organization validation - */ -export async function getEquipmentByCode( - organizationId: string, - qrNfcCode: string -): Promise { - try { - // Security check - validates user belongs to this organization - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter for security - const filter = createOrganizationFilter( - organizationId, - `qrNfcCode="${qrNfcCode}"` - ) - return await pb.collection('equipment').getFirstListItem(filter) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getEquipmentByCode') - } -} - -/** - * Get equipment list with pagination and security checks - */ -export async function getEquipmentList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) - - return await pb.collection('equipment').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getEquipmentList') - } -} - -/** - * Get all equipment for an organization with security check - */ -export async function getOrganizationEquipment( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter - fixed field name to match interface - const filter = `organizationId=${organizationId}` - - return await pb.collection('equipment').getFullList({ - filter, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'EquipmentService.getOrganizationEquipment' - ) - } -} - -/** - * Create a new equipment item with permission check - */ -export async function createEquipment( - organizationId: string, - data: Pick< - Partial, - | 'name' - | 'qrNfcCode' - | 'tags' - | 'notes' - | 'acquisitionDate' - | 'parentEquipmentId' - > -): Promise { - try { - // Security check - requires WRITE permission - removed unused user variable - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set and matches the authenticated user's org - // Fixed field name to match interface - return await pb.collection('equipment').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.createEquipment') - } -} - -/** - * Update an equipment item with permission and ownership checks - */ -export async function updateEquipment( - id: string, - data: Pick< - Partial, - | 'name' - | 'qrNfcCode' - | 'tags' - | 'notes' - | 'acquisitionDate' - | 'parentEquipmentId' - > -): Promise { - try { - // Security check - validates organization and requires WRITE permission - await validateResourceAccess( - ResourceType.EQUIPMENT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Never allow changing the organization - const sanitizedData = { ...data } - // Fixed 'any' type and field name - delete (sanitizedData as Record).organizationId - - return await pb.collection('equipment').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.updateEquipment') - } -} - -/** - * Delete an equipment item with permission check - */ -export async function deleteEquipment(id: string): Promise { - try { - // Security check - requires ADMIN permission for deletion - await validateResourceAccess( - ResourceType.EQUIPMENT, - id, - PermissionLevel.ADMIN - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('equipment').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.deleteEquipment') - } -} - -/** - * Get child equipment (items that have this equipment as parent) - */ -export async function getChildEquipment( - parentId: string -): Promise { - try { - // Security check - validates parent equipment access - const { organizationId } = await validateResourceAccess( - ResourceType.EQUIPMENT, - parentId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter for security - fixed field name - const filter = createOrganizationFilter( - organizationId, - `parentEquipmentId="${parentId}"` - ) - - return await pb.collection('equipment').getFullList({ - filter, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getChildEquipment') - } -} - -/** - * Generate a unique QR/NFC code - */ -export async function generateUniqueCode(): Promise { - // Generate a random alphanumeric code - const prefix = 'EQ' - const randomPart = Math.random().toString(36).substring(2, 10).toUpperCase() - return `${prefix}-${randomPart}` -} - -/** - * Get equipment count for an organization - */ -export async function getEquipmentCount( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const result = await pb.collection('equipment').getList(1, 1, { - filter: `organizationId="${organizationId}"`, // Fixed field name - skipTotal: false, - }) - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getEquipmentCount') - } -} - -/** - * Search equipment by name or tag within organization - */ -export async function searchEquipment( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('equipment').getFullList({ - filter: pb.filter( - 'organizationId = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.searchEquipment') - } -} - ----- -src/app/actions/services/pocketbase/baseService.ts -import 'server-only' -import PocketBase from 'pocketbase' - -// Singleton pattern for PocketBase instance -let instance: PocketBase | null = null - -/** - * Initialize and authenticate with PocketBase - * Uses server-side authentication with an admin token - * - * @returns {Promise} Authenticated PocketBase instance or null if authentication fails - */ -export const getPocketBase = async (): Promise => { - // Return existing instance if valid - if (instance?.authStore?.isValid) { - return instance - } - - // Get credentials from environment variables - const token = process.env.PB_USER_TOKEN - const url = process.env.PB_SERVER_URL - - if (!token || !url) { - console.error('Missing PocketBase credentials in environment variables') - return null - } - - // Create new PocketBase instance - instance = new PocketBase(url) - instance.authStore.save(token, null) - instance.autoCancellation(false) - - return instance -} - -/** - * Error handler for PocketBase operations - * @param error The caught error - * @param context Optional context information for better error reporting - */ -export const handlePocketBaseError = ( - error: unknown, - context?: string -): never => { - const contextMsg = context ? ` [${context}]` : '' - console.error(`PocketBase error${contextMsg}:`, error) - - if (error instanceof Error) { - throw new Error( - `PocketBase operation failed${contextMsg}: ${error.message}` - ) - } - - throw new Error(`Unknown PocketBase error${contextMsg}`) -} - ----- -src/app/actions/services/pocketbase/assignmentService.ts -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, - ResourceType, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { Assignment, ListOptions, ListResult } from '@/types/types_pocketbase' - -/** - * Get a single assignment by ID with security validation - */ -export async function getAssignment(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('assignments').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'AssignmentService.getAssignment') - } -} - -/** - * Get assignments list with pagination and security checks - */ -export async function getAssignmentsList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) - - return await pb.collection('assignments').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.getAssignmentsList') - } -} - -/** - * Get active assignments for an organization with security checks - * Active assignments have startDate ≤ current date and no endDate or endDate ≥ current date - */ -export async function getActiveAssignments( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - - return await pb.collection('assignments').getFullList({ - expand: 'equipmentId,assignedToUserId,assignedToProjectId', - filter: pb.filter( - 'organizationId = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { now, orgId: organizationId } - ), - sort: '-created', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getActiveAssignments' - ) - } -} - -/** - * Get current assignment for a specific equipment with security checks - */ -export async function getCurrentEquipmentAssignment( - equipmentId: string -): Promise { - try { - // Security check - validates access to the equipment - const { organizationId } = await validateResourceAccess( - ResourceType.EQUIPMENT, - equipmentId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - - // Include organization check for extra security - const assignments = await pb.collection('assignments').getList(1, 1, { - expand: 'equipmentId,assignedToUserId,assignedToProjectId', - filter: pb.filter( - 'organizationId = {:orgId} && equipmentId = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { equipId: equipmentId, now, orgId: organizationId } - ), - sort: '-created', - }) - - return assignments.items.length > 0 - ? (assignments.items[0] as Assignment) - : null - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getCurrentEquipmentAssignment' - ) - } -} - -/** - * Get assignments for a user with security checks - */ -export async function getUserAssignments( - userId: string -): Promise { - try { - // Security check - validates access to the user - const { organizationId } = await validateResourceAccess( - ResourceType.USER, - userId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Include organization filter for security - return await pb.collection('assignments').getFullList({ - expand: 'equipmentId,assignedToProjectId', - filter: createOrganizationFilter( - organizationId, - `assignedToUserId="${userId}"` - ), - sort: '-created', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.getUserAssignments') - } -} - -/** - * Get assignments for a project with security checks - */ -export async function getProjectAssignments( - projectId: string -): Promise { - try { - // Security check - validates access to the project - const { organizationId } = await validateResourceAccess( - ResourceType.PROJECT, - projectId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Include organization filter for security - return await pb.collection('assignments').getFullList({ - expand: 'equipmentId,assignedToUserId', - filter: createOrganizationFilter( - organizationId, - `assignedToProjectId=${projectId}` - ), - sort: '-created', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getProjectAssignments' - ) - } -} - -/** - * Create a new assignment with security checks - */ -export async function createAssignment( - organizationId: string, - data: Pick< - Partial, - | 'equipmentId' - | 'assignedToUserId' - | 'assignedToProjectId' - | 'startDate' - | 'endDate' - | 'notes' - > -): Promise { - try { - // Security check - requires WRITE permission - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - // If equipment is provided, verify access to it - if (data.equipmentId) { - await validateResourceAccess( - ResourceType.EQUIPMENT, - data.equipmentId, - PermissionLevel.READ - ) - } - - // If assignedToUser is provided, verify access to that user - if (data.assignedToUserId) { - await validateResourceAccess( - ResourceType.USER, - data.assignedToUserId, - PermissionLevel.READ - ) - } - - // If assignedToProject is provided, verify access to that project - if (data.assignedToProjectId) { - await validateResourceAccess( - ResourceType.PROJECT, - data.assignedToProjectId, - PermissionLevel.READ - ) - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set correctly - return await pb.collection('assignments').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.createAssignment') - } -} - -/** - * Update an assignment with security checks - */ -export async function updateAssignment( - id: string, - data: Pick< - Partial, - | 'equipmentId' - | 'assignedToUserId' - | 'assignedToProjectId' - | 'startDate' - | 'endDate' - | 'notes' - > -): Promise { - try { - // Security check - requires WRITE permission for the assignment - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.WRITE - ) - - // Additional validations for related resources - if (data.equipmentId) { - await validateResourceAccess( - ResourceType.EQUIPMENT, - data.equipmentId, - PermissionLevel.READ - ) - } - - if (data.assignedToUserId) { - await validateResourceAccess( - ResourceType.USER, - data.assignedToUserId, - PermissionLevel.READ - ) - } - - if (data.assignedToProjectId) { - await validateResourceAccess( - ResourceType.PROJECT, - data.assignedToProjectId, - PermissionLevel.READ - ) - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Never allow changing the organization - const sanitizedData = { ...data } - // Use type assertion with more specific type - delete (sanitizedData as Record).organizationId - - return await pb.collection('assignments').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.updateAssignment') - } -} - -/** - * Delete an assignment with security checks - */ -export async function deleteAssignment(id: string): Promise { - try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('assignments').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.deleteAssignment') - } -} - -/** - * Complete an assignment by setting its end date to now with security checks - */ -export async function completeAssignment(id: string): Promise { - try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('assignments').update(id, { - endDate: new Date().toISOString(), - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.completeAssignment') - } -} - -/** - * Get assignment history for an equipment with security checks - */ -export async function getEquipmentAssignmentHistory( - equipmentId: string -): Promise { - try { - // Security check - validates access to the equipment - const { organizationId } = await validateResourceAccess( - ResourceType.EQUIPMENT, - equipmentId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Include organization filter for security - return await pb.collection('assignments').getFullList({ - expand: 'assignedToUserId,assignedToProjectId', - filter: createOrganizationFilter( - organizationId, - `equipmentId="${equipmentId}"` - ), - sort: '-startDate', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getEquipmentAssignmentHistory' - ) - } -} - ----- -src/app/actions/equipment/manageEquipments.ts -'use server' - -import { - createEquipment, - updateEquipment, - deleteEquipment, - generateUniqueCode, -} from '@/app/actions/services/pocketbase/equipmentService' -import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' -import { Equipment } from '@/types/types_pocketbase' -import { revalidatePath } from 'next/cache' -import { z } from 'zod' - -// Define validation schema for equipment data -const equipmentSchema = z.object({ - acquisitionDate: z.string().optional(), - name: z.string().min(2, 'Name must be at least 2 characters'), - notes: z.string().optional(), - parentEquipment: z.string().optional(), - tags: z.array(z.string()).optional(), -}) - -type EquipmentFormData = z.infer - -/** - * Result type for all equipment actions - */ -export type EquipmentActionResult = { - success: boolean - message?: string - data?: Equipment - validationErrors?: Record -} - -/** - * Convert tags array to string for PocketBase storage - */ -function convertTagsForStorage(tags?: string[]): string | null { - if (!tags || tags.length === 0) return null - return JSON.stringify(tags) -} - -/** - * Create a new equipment item - */ -export async function createEquipmentAction( - organizationId: string, - formData: EquipmentFormData -): Promise { - try { - // Validate input data - const validatedData = equipmentSchema.parse(formData) - - // Generate unique code for the equipment - const qrNfcCode = await generateUniqueCode() - - // Create the equipment with security checks built into the service - const newEquipment = await createEquipment(organizationId, { - acquisitionDate: validatedData.acquisitionDate || null, - name: validatedData.name, - notes: validatedData.notes || null, - parentEquipmentId: validatedData.parentEquipment || null, - qrNfcCode, - tags: convertTagsForStorage(validatedData.tags), - }) - - // Revalidate relevant paths to refresh data - revalidatePath('/dashboard/equipment') - - return { - data: newEquipment, - message: 'Equipment created successfully', - success: true, - } - } catch (error) { - // Handle validation errors - if (error instanceof z.ZodError) { - const validationErrors = error.errors.reduce( - (acc, curr) => { - const key = curr.path.join('.') - acc[key] = curr.message - return acc - }, - {} as Record - ) - - return { - message: 'Validation failed', - success: false, - validationErrors, - } - } - - // Handle security errors - if (error instanceof SecurityError) { - return { - message: error.message, - success: false, - } - } - - // Handle other errors - console.error('Error creating equipment:', error) - return { - message: - error instanceof Error ? error.message : 'An unknown error occurred', - success: false, - } - } -} - -/** - * Update an existing equipment item - */ -export async function updateEquipmentAction( - equipmentId: string, - formData: EquipmentFormData -): Promise { - try { - // Validate input data - const validatedData = equipmentSchema.parse(formData) - - // Update the equipment with security checks built into the service - const updatedEquipment = await updateEquipment(equipmentId, { - acquisitionDate: validatedData.acquisitionDate || null, - name: validatedData.name, - notes: validatedData.notes || null, - parentEquipmentId: validatedData.parentEquipment || null, - tags: convertTagsForStorage(validatedData.tags), - }) - - // Revalidate relevant paths to refresh data - revalidatePath('/dashboard/equipment') - revalidatePath(`/dashboard/equipment/${equipmentId}`) - - return { - data: updatedEquipment, - message: 'Equipment updated successfully', - success: true, - } - } catch (error) { - // Handle validation errors - if (error instanceof z.ZodError) { - const validationErrors = error.errors.reduce( - (acc, curr) => { - const key = curr.path.join('.') - acc[key] = curr.message - return acc - }, - {} as Record - ) - - return { - message: 'Validation failed', - success: false, - validationErrors, - } - } - - // Handle security errors - if (error instanceof SecurityError) { - return { - message: error.message, - success: false, - } - } - - // Handle other errors - console.error('Error updating equipment:', error) - return { - message: - error instanceof Error ? error.message : 'An unknown error occurred', - success: false, - } - } -} - -/** - * Delete an equipment item - */ -export async function deleteEquipmentAction( - equipmentId: string -): Promise { - try { - // Delete the equipment with security checks built into the service - await deleteEquipment(equipmentId) - - // Revalidate relevant paths to refresh data - revalidatePath('/dashboard/equipment') - - return { - message: 'Equipment deleted successfully', - success: true, - } - } catch (error) { - // Handle security errors - if (error instanceof SecurityError) { - return { - message: error.message, - success: false, - } - } - - // Handle other errors - console.error('Error deleting equipment:', error) - return { - message: - error instanceof Error ? error.message : 'An unknown error occurred', - success: false, - } - } -} - ----- -src/app/(application)/app/page.tsx -import { CardDescription, CardTitle } from '@/components/ui/card' -import SpotlightCard from '@/components/ui/spotlight-card' -import { auth, currentUser } from '@clerk/nextjs/server' -import { - Construction, - Wrench, - User, - Building, - Scan, - ClipboardList, -} from 'lucide-react' -import Link from 'next/link' -import { redirect } from 'next/navigation' - -export default async function Dashboard() { - const { orgId, userId } = await auth() - - if (!userId || !orgId) { - redirect('/onboarding') - } - - const user = await currentUser() - - const quickLinks = [ - { - bgColor: 'bg-blue-100', - color: 'text-blue-600', - description: 'Gérer et suivre tous les équipements et outils', - href: '/app/equipments', - icon: Wrench, - spotlightColor: - 'rgba(59, 130, 246, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, - title: 'Équipements', - }, - { - bgColor: 'bg-amber-100', - color: 'text-amber-600', - description: 'Gérer les projets, chantiers et emplacements', - href: '/app/projects', - icon: Construction, - spotlightColor: - 'rgba(245, 158, 11, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, - title: 'Projets', - }, - { - bgColor: 'bg-green-100', - color: 'text-green-600', - description: 'Gérer les utilisateurs et permissions', - href: '/app/users', - icon: User, - spotlightColor: - 'rgba(34, 197, 94, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, - title: 'Utilisateurs', - }, - { - bgColor: 'bg-purple-100', - color: 'text-purple-600', - description: 'Scanner et localiser des équipements', - href: '/app/scan', - icon: Scan, - spotlightColor: - 'rgba(168, 85, 247, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, - title: 'Scanner', - }, - { - bgColor: 'bg-red-100', - color: 'text-red-600', - description: 'Rapports et inventaire complet', - href: '/app/inventory', - icon: ClipboardList, - spotlightColor: - 'rgba(239, 68, 68, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, - title: 'Inventaire', - }, - { - bgColor: 'bg-indigo-100', - color: 'text-indigo-600', - description: 'Paramètres et configuration', - href: '/organizations', - icon: Building, - spotlightColor: - 'rgba(99, 102, 241, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, - title: 'Organisation', - }, - ] - - return ( -
-

- Bonjour {user?.firstName} ! -

-
- {quickLinks.map(link => ( - - -
-
- -
- -
- - {link.title} - - - {link.description} - -
-
-
- - ))} -
-
- ) -} - ----- -src/app/(application)/app/layout.tsx -import type { Metadata } from 'next' -import type React from 'react' - -import { AppSidebar } from '@/components/app/app-sidebar' -import { TopBar } from '@/components/app/top-bar' -import '@/app/globals.css' -import { SidebarProvider } from '@/components/ui/sidebar' -import { - ClerkProvider, - RedirectToSignIn, - SignedIn, - SignedOut, -} from '@clerk/nextjs' - -export const metadata: Metadata = { - title: { - default: 'ForTooling', - template: '%s - ForTooling', - }, -} - -export default function AppLayout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - -
- - -
- -
-
- {children} -
-
-
-
-
-
- - - -
- - - ) -} - ----- -src/app/(application)/app/actions/user.ts -'use server' - -import { auth, clerkClient } from '@clerk/nextjs/server' - -/** - * Marks the user's onboarding as complete by setting metadata - * This allows the application to know that the user has completed the onboarding process - * and shouldn't be redirected to the onboarding page again - */ -export async function markOnboardingComplete(): Promise { - const { userId } = await auth() - - if (!userId) { - throw new Error('Authentication required') - } - - try { - // Update the user's public metadata - // hasCompletedOnboarding=true indicates the user has finished onboarding - // onboardingCompletedAt stores the date when onboarding was completed - const clerkClientInstance = await clerkClient() - await clerkClientInstance.users.updateUserMetadata(userId, { - publicMetadata: { - hasCompletedOnboarding: true, - onboardingCompletedAt: new Date().toISOString(), - }, - }) - - return true - } catch (error) { - console.error('Error updating user metadata:', error) - throw new Error('Failed to complete onboarding') - } -} - ----- -src/app/(application)/(clerk)/layout.tsx -import '@/app/globals.css' - -import type { Metadata } from 'next' -import type React from 'react' - -import { AppSidebar } from '@/components/app/app-sidebar' -import { TopBar } from '@/components/app/top-bar' -import { SidebarProvider } from '@/components/ui/sidebar' -import { ClerkProvider } from '@clerk/nextjs' - -export const metadata: Metadata = { - title: { - default: "ForTooling - Gestion de l'outillage", - template: '%s - ForTooling', - }, -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { - return ( - - - - - - -
- - -
- -
-
{children}
-
-
-
-
- - -
- ) -} - ----- -src/app/(application)/(clerk)/waitlist/[[...waitlist]]/page.tsx -import { Container } from '@/components/app/container' -import { Waitlist } from '@clerk/nextjs' - -export default function WaitlistPage() { - return ( - - - - ) -} - ----- -src/app/(application)/(clerk)/sign-up/[[...sign-up]]/page.tsx -import { Container } from '@/components/app/container' -import { SignUp } from '@clerk/nextjs' - -export default function SignUpPage() { - return ( - - - - ) -} - ----- -src/app/(application)/(clerk)/sign-in/[[...sign-in]]/page.tsx -import { Container } from '@/components/app/container' -import { SignIn } from '@clerk/nextjs' - -export default function SignInPage() { - return ( - - - - ) -} - ----- -src/app/(application)/(clerk)/organizations/page.tsx -import { Container } from '@/components/app/container' -import { OrganizationList } from '@clerk/nextjs' - -export default function OrganizationsPage() { - return ( - - - - ) -} - ----- -src/app/(application)/(clerk)/organization-profile/[[...organization-profile]]/page.tsx -import { Container } from '@/components/app/container' -import { OrganizationProfile } from '@clerk/nextjs' - -export default function OrganizationProfilePage() { - return ( - - - - ) -} - ----- -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/page.tsx -'use client' - -import { CompletionStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep' -import { FeaturesStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/FeaturesStep' -import { OrganizationStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep' -import { ProfileStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/ProfileStep' -import { WelcomeStep } from '@/app/(application)/(clerk)/onboarding/[[...onboarding]]/WelcomeStep' -import { markOnboardingComplete } from '@/app/(application)/app/actions/user' -import { Container } from '@/components/app/container' -import { Button } from '@/components/ui/button' -import { - Stepper, - StepperItem, - StepperTrigger, - StepperIndicator, - StepperSeparator, - StepperTitle, - StepperDescription, -} from '@/components/ui/stepper' -import { OnboardingStep, useOnboardingStore } from '@/stores/onboarding-store' -import { - useUser, - SignedIn, - SignedOut, - RedirectToSignIn, - useOrganization, -} from '@clerk/nextjs' -import { - ArrowLeft, - ArrowRight, - Building, - CheckCircle2, - Info, - User, - Laptop, -} from 'lucide-react' -import { useRouter } from 'next/navigation' -import { useState, useEffect } from 'react' - -// Onboarding steps data -const steps = [ - { - description: 'Bienvenue sur ForTooling', - icon: , - id: 1, - title: 'Bienvenue', - }, - { - description: 'Découvrez les fonctionnalités clés', - icon: , - id: 2, - title: 'Fonctionnalités', - }, - { - description: 'Configurez votre organisation', - icon: , - id: 3, - title: 'Organisation', - }, - { - description: 'Complétez votre profil', - icon: , - id: 4, - title: 'Profil', - }, - { - description: 'Vous êtes prêt à commencer', - icon: , - id: 5, - title: 'Prêt !', - }, -] - -export default function OnboardingPage() { - const { isLoaded, isSignedIn, user } = useUser() - const router = useRouter() - const { organization } = useOrganization() - - // Use the Zustand store - const { currentStep, isLoading, setCurrentStep, setIsLoading } = - useOnboardingStore() - - // Step content components stored in an array - const [stepContents, setStepContents] = useState([]) - - useEffect(() => { - // Check if user has completed onboarding - if ( - isLoaded && - isSignedIn && - user?.publicMetadata?.hasCompletedOnboarding - ) { - router.push('/app') - } - - // Initialize step contents - setStepContents([ - , - , - , - , - , - ]) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoaded, isSignedIn, user, router, isLoading, organization]) - - const goToNextStep = () => { - // Check if we're on the organization step (index 2) and block if no organization - if (currentStep === 3 && !organization) { - return // Block progression if no organization - } - - if (currentStep < 5) { - setCurrentStep((currentStep + 1) as OnboardingStep) - } - } - - const goToPreviousStep = () => { - if (currentStep > 1) { - setCurrentStep((currentStep - 1) as OnboardingStep) - } - } - - async function completeOnboarding() { - setIsLoading(true) - try { - await markOnboardingComplete() - router.push('/app') - } catch (error) { - console.error('Failed to complete onboarding:', error) - } finally { - setIsLoading(false) - } - } - - if (!isLoaded) { - return ( -
-
Chargement...
-
- ) - } - - return ( - <> - - -
-
-

- Bienvenue sur votre plateforme de gestion d'équipements -

-

- Suivez ces quelques étapes pour configurer votre compte et - commencer à utiliser ForTooling -

-
- -
- - {steps.map((step, index) => ( - step.id} - disabled={currentStep < step.id} - loading={isLoading && currentStep === step.id} - className='[&:not(:last-child)]:flex-1' - > - - currentStep >= step.id && - setCurrentStep(step.id as OnboardingStep) - } - > - {step.icon} -
- {step.title} - - {step.description} - -
-
- {index < steps.length - 1 && } -
- ))} -
-
- -
- {stepContents[currentStep - 1]} -
- -
- - - {currentStep < 5 ? ( - - ) : ( - - )} -
-
-
-
- - - - - ) -} - ----- -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/WelcomeStep.tsx -'use client' -import Image from 'next/image' - -export function WelcomeStep() { - return ( -
-
-
- ForTooling Logo -
-
-

- Bienvenue sur ForTooling -

-

- Notre solution vous aide à suivre, attribuer et maintenir votre parc - d'équipements de manière simple
- et efficace grâce aux technologies NFC et QR code. -

-
- À jamais les casse-têtes de la gestion de votre parc d'équipements. -
-
- ) -} - ----- -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/ProfileStep.tsx -'use client' -import { UserProfile } from '@clerk/nextjs' - -export function ProfileStep() { - return ( -
-

- Complétez votre profil -

-

- Ajoutez quelques informations pour personnaliser votre expérience et - faciliter la collaboration -

-
- -
-
- ) -} - ----- -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx -import { Button } from '@/components/ui/button' -import { Organization } from '@clerk/nextjs/server' -import { Building, User, Info, CheckCircle2 } from 'lucide-react' -import Image from 'next/image' -import { useRouter } from 'next/navigation' - -export function OrganizationStep({ - hasOrganization, - organization, -}: { - hasOrganization: boolean - organization: Organization -}) { - const router = useRouter() - - return ( -
-

- Configurez votre organisation -

-

- Vous devez créer ou rejoindre une organisation pour continuer. Cela - permettra de gérer les équipements et utilisateurs de votre entreprise. -

- - {hasOrganization ? ( -
-
- -
-

- Organisation configurée avec succès! -

-

- Vous pouvez continuer vers l'étape suivante. -

-
- ForTooling Logo - {organization?.imageUrl && ( - <> - x - Organization Logo - - )} -
-
- ) : ( - <> -
- - -
-
-
-

- Note importante -

-

- Vous devez créer ou rejoindre une organisation avant de pouvoir - continuer. Cliquez sur l'un des boutons ci-dessus, puis - revenez à cette page. -

-
-
- - )} -
- ) -} - ----- -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/FeaturesStep.tsx -import { ClipboardList, Construction, Scan, Wrench } from 'lucide-react' - -export function FeaturesStep() { - return ( -
-

- Découvrez nos fonctionnalités clés -

-
-
-
- -

Suivi d'équipements

-
-

- Localisez et suivez tous vos équipements en temps réel avec la - technologie NFC/QR -

-
-
-
- -

Attribution aux projets

-
-

- Affectez facilement des équipements aux utilisateurs et aux projets -

-
-
-
- -

Scan rapide

-
-

- Scannez les équipements en quelques secondes pour obtenir leur - statut et les gérer -

-
-
-
- -

Rapports détaillés

-
-

- Générez des analyses détaillées sur l'utilisation de votre parc - matériel -

-
-
-
- ) -} - ----- -src/app/(application)/(clerk)/onboarding/[[...onboarding]]/CompletionStep.tsx -'use client' - -import { Button } from '@/components/ui/button' -import confetti from 'canvas-confetti' -import { CheckCircle2, ThumbsUp, Rocket, CircleCheck } from 'lucide-react' -import { useEffect, useRef } from 'react' - -export function CompletionStep({ - isLoading, - onComplete, -}: { - onComplete: () => void - isLoading: boolean -}) { - const confettiTriggered = useRef(false) - - useEffect(() => { - // Trigger confetti animation when component loads - // But only once (using useRef for tracking) - if (!confettiTriggered.current) { - triggerConfetti() - confettiTriggered.current = true - } - }, []) - - const triggerConfetti = () => { - const end = Date.now() + 3 * 1000 // 3 seconds - const colors = ['#a786ff', '#fd8bbc', '#eca184', '#f8deb1'] - - // Side cannons animation (left and right) - const frame = () => { - if (Date.now() > end) return - - // Left side - confetti({ - angle: 60, - colors: colors, - origin: { x: 0, y: 0.5 }, - particleCount: 2, - spread: 55, - startVelocity: 60, - }) - - // Right side - confetti({ - angle: 120, - colors: colors, - origin: { x: 1, y: 0.5 }, - particleCount: 2, - spread: 55, - startVelocity: 60, - }) - - requestAnimationFrame(frame) - } - - frame() - - // Add a central burst at the beginning - confetti({ - origin: { y: 0.6 }, - particleCount: 100, - spread: 70, - }) - } - - return ( -
-
-
- -
-
- -

Félicitations ! 🎉

-

- Vous êtes prêt à utiliser ForTooling -

- -
-

- Votre compte est maintenant configuré. Vous pouvez commencer à - utiliser ForTooling pour optimiser la gestion de votre parc - d'équipements. -

-
- -
-
- -

Gestion simplifiée

-
-
- -

Performance optimisée

-
-
- -

Expérience optimale

-
-
- -
-

- Notre équipe est disponible pour vous aider si vous avez des - questions. N'hésitez pas à nous contacter à{' '} - contact@fortooling.com -

-
- -
- -
-
- ) -} - ----- -src/app/(application)/(clerk)/create-organization/[[...create-organization]]/page.tsx -'use client' - -import { Container } from '@/components/app/container' -import { CreateOrganization } from '@clerk/nextjs' - -export default function CreateOrganizationPage() { - return ( - - - - ) -} - ----- -docs-and-prompts/technique-prompt-system.md -# Prompt Système pour Assistant de Développement SaaS - Plateforme de Gestion d'Équipements NFC/QR - -## 🎯 Contexte du Projet - -Tu es un assistant de développement expert spécialisé dans la création d'une plateforme SaaS de gestion d'équipements avec tracking NFC/QR. Ce système permet aux entreprises de suivre, attribuer et maintenir leur parc d'équipements via une interface moderne et des fonctionnalités avancées de scanning et de reporting. - -## 📋 Directives Générales - -- **Langue**: Toujours coder et commenter en anglais -- **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques -- **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown -- **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code -- **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée -- **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité - -## 🏗️ Stack Technique à Respecter - -### Frontend - -- **Framework**: Next.js 15+, React 19+ -- **Styling**: Tailwind CSS 4+, shadcn/ui -- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 -- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) -- **Forms**: Tan Stack Form + Zod pour la validation -- **Animations**: Framer Motion, Rive pour les animations complexes -- **UI**: Composants shadcn/ui, icônes Lucide React -- **Mobile**: next-pwa, WebNFC API, QR code fallback - -### Backend - -- **API**: Next.js Server Actions avec middleware de protection centralisé -- **Validation**: Zod pour la validation des données -- **ORM**: Prisma avec PostgreSQL -- **Authentification**: Clerk 6+ -- **Paiements**: Stripe -- **Recherche**: Algolia -- **Stockage**: Cloudflare R2 -- **Emails**: Resend -- **SMS**: Twilio -- **Temps réel**: Socket.io -- **Tâches asynchrones**: Temporal.io - -### DevOps & Sécurité - -- **Déploiement**: Coolify, Docker -- **CI/CD**: GitHub Actions -- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip -- **Analytics**: Umami -- **Sécurité API**: Rate limiting, CORS, Helmet - -## 11. Schéma / visualisation - -Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. -Si il y a besoin de schémas, il faut les les créer avec [Mermaid](https://mermaid-js.github.io/) et suivre les bonnes pratiques de ce langage. - -## 🖋️ Conventions de Code & Documentation - -### Structuration du Code - -- Architecture modulaire et maintenable -- Séparation claire des préoccupations (SoC) -- DRY (Don't Repeat Yourself) et SOLID principles -- Pattern par fonctionnalité plutôt que par type technique -- Centralisation des vérifications de sécurité et d'autorisation - -### Style de Code - -- **TypeScript**: Types stricts et exhaustifs -- **React**: Composants fonctionnels avec hooks -- **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) -- **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types -- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement - -### Documentation - -- **JSDoc** pour toutes les fonctions, hooks, et types complexes: - -```typescript -/** - * Fetches equipment data based on provided filters - * @param {EquipmentFilters} filters - The filters to apply to the query - * @param {QueryOptions} options - Optional query parameters - * @returns {Promise} Array of equipment matching filters - * @throws {ApiError} When the API request fails - */ -``` - -- **Commentaires de code**: Explique le "pourquoi", pas le "quoi" -- Ajoute des logs explicatifs aux endroits clés - -### Tests - -- Tests unitaires avec Vitest -- Tests end-to-end avec Playwright -- Privilégier les tests pour la logique métier critique - -## 📐 Structure de Projet Attendue - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` - -## 🤝 Collaboration Attendue - -- **Proactivité**: Anticipe les besoins et problèmes potentiels -- **Pédagogie**: Explique les concepts complexes et les choix d'architecture -- **Adaptabilité**: Ajuste-toi à mes besoins et préférences au fur et à mesure -- **Progressivité**: Commence par les fondamentaux puis avance vers des implémentations plus complexes -- **Optimisations**: Suggère des améliorations mais priorise la lisibilité et la maintenabilité - -## 🚨 Anti-patterns à Éviter - -- Ne pas utiliser de classes React (préférer les composants fonctionnels) -- Éviter les any/unknown en TypeScript si possible -- Ne pas réinventer ce qui existe déjà dans les bibliothèques choisies -- Éviter les dépendances inutiles ou redondantes -- Ne pas mélanger les styles (préférer Tailwind) -- Éviter d'exposer des données sensibles dans le frontend -- Ne pas dupliquer la logique d'authentification et de validation -- Éviter de créer des Server Actions sans utiliser le middleware de protection - -## 🔄 Processus de Travail - -1. Comprends d'abord mon besoin ou problème -2. Propose une approche structurée avec les technologies appropriées -3. Implémente en expliquant les choix techniques -4. Suggère des améliorations ou alternatives si pertinent -5. Offre des conseils pour les tests et la maintenance - -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. - ----- -docs-and-prompts/stack-technique.md -# Stack Technique Finale - Plateforme SaaS de Gestion d'Équipements NFC/QR - -## 1. Vue d'ensemble - -Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les technologies modernes du web pour offrir une solution robuste, performante et évolutive. L'architecture est conçue pour être hautement optimisée, sécurisée et facile à maintenir. - -## 2. Frontend - -### Framework & UI - -- **Next.js 15+** - Framework React avec App Router et Server Components -- **React 19+** - Bibliothèque UI pour construire des interfaces interactives -- **Tailwind CSS 4+** - Framework CSS utility-first pour le styling -- **shadcn/ui** - Composants UI réutilisables basés sur Radix UI -- **Lucide React** - Bibliothèque d'icônes SVG -- **Framer Motion** - Animations et transitions fluides -- **Rive** - Animations complexes et interactives - -### Gestion d'état client - -- **Zustand** - Gestion d'état global légère et simple - - Utilisé pour éviter le prop drilling - - Stockage des préférences utilisateur, thèmes, filtres - - État partagé entre composants distants - -### PWA & Mobile - -- **next-pwa** - Transforme l'application en Progressive Web App -- **WebNFC API** - Accès aux fonctionnalités NFC pour les appareils compatibles -- **QR Code fallback** - Solution alternative pour les appareils sans NFC - -### Qualité & Tests - -- **TypeScript** - Typage statique pour une meilleure qualité de code -- **ESLint/Prettier** - Linting et formatage de code -- **Vitest** - Tests unitaires rapides -- **Playwright** - Tests end-to-end - -## 3. Backend & API - -### API & Validation - -- **Next.js Server Actions** - Actions serveur typées et sécurisées - - Pattern de protection centralisé (HOF withProtection) - - Isolation multi-tenant intégrée -- **Zod** - Validation de schémas pour les données d'entrée -- **Tan stack Form** - Gestion de formulaires avec validation côté client - -### Backend - -- **Pockebase** - Backend as a service - -### Sécurité API - -- **Rate limiting** - Protection contre les abus -- **CORS** - Sécurité pour les requêtes cross-origin -- **Helmet** - Sécurisation des headers HTTP - -## 4. Services & Intégrations - -### Authentification & Paiements - -- **Clerk 6+** - Authentification complète et gestion des utilisateurs -- **Stripe** - Traitement des paiements et gestion des abonnements - -### Recherche & Stockage - -- **Algolia** - Recherche rapide et pertinente -- **Cloudflare R2** - Stockage d'objets compatible S3 - -### Communication & Notifications - -- **Resend** - Service d'emails transactionnels -- **Twilio** - SMS et notifications mobiles -- **Socket.io** - Communication temps réel pour le monitoring - -### Fonctionnalités spécifiques - -- **OpenStreetMap + Leaflet.js** - Cartographie et géolocalisation -- **React-PDF** - Génération de rapports PDF -- **SheetJS** - Export de données en format Excel -- **Temporal.io** - Orchestration de workflows et tâches asynchrones - -## 5. Infrastructure & DevOps - -### Déploiement & CI/CD - -- **Coolify** - Plateforme self-hosted pour le déploiement -- **Docker** - Conteneurisation des services -- **GitHub Actions** - Automatisation CI/CD - -### Monitoring & Observabilité - -- **Prometheus + Grafana** - Collecte et visualisation de métriques -- **Loki** - Agrégation et exploration de logs -- **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) -- **Umami** - Analytics respectueux de la vie privée - -### Sauvegarde & Restauration - -- **pgbackrest** - Solution de backup robuste pour PostgreSQL -- **pg_dump automatisé** - Sauvegardes programmées - -## 6. Architecture multi-tenant - -- Architecture à schéma unique avec discrimination par tenant_id -- Isolation des données par organisation au niveau des Server Actions -- Middleware de protection centralisé pour les vérifications d'accès -- Optimisation des requêtes grâce aux index sur tenant_id - -## 7. Intégration NFC/QR - -- Approche hybride WebNFC + QR Code -- Points de scan fixes (entrées/sorties) -- Options pour scanners Bluetooth dans les zones de forte utilisation - -## 8. Optimisations & Performance - -- **SEO** - Optimisation pour la partie publique (landing) - - Screaming Frog pour l'audit - - Lighthouse pour les bonnes pratiques -- **Web Vitals** - Suivi continu des métriques de performance -- **Unlighthouse/IBM checker** - Outils d'analyse supplémentaires - -## 9. Documentation - -- **Swagger/OpenAPI** - Documentation d'API auto-générée -- **Docusaurus** - Documentation utilisateur et technique - -## 10. Structure du projet - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` - ----- -docs-and-prompts/diagram-mermaid.md -# Diagram - -```text -erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} - -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} - -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} - -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} - -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} - -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} - -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees - -User }o--o{ Assignment : "is assigned to" - -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" - -Project }o--o{ Assignment : includes -``` - ----- -docs-and-prompts/cahier-des-charges.md -## 1. Contexte et problématique générale - -### 1.1 Problématique adressée - -De nombreuses entreprises possèdent et gèrent un parc d'équipements qu'elles doivent suivre, attribuer et entretenir. Ces équipements peuvent représenter plusieurs dizaines à centaines d'articles différents (outils, matériel technique, appareils spécialisés, etc.). - -Les systèmes traditionnels de gestion présentent des lacunes importantes : - -- Suivi manuel chronophage et source d'erreurs -- Difficulté à localiser rapidement les équipements -- Absence d'historique fiable des mouvements et utilisations -- Complexité pour gérer les attributions -- Manque de visibilité globale sur l'état du parc -- Coût très elevé pour des balises gps précies (e.g hilti etc) -- Impossibilité d'appliquer ça sur des éléments autres - -## 2. Objectifs de la plateforme SaaS - -Développer une plateforme SaaS de gestion d'équipements qui permettra de : - -- Centraliser l'inventaire complet du parc matériel -- Suivre la localisation de chaque équipement en temps réel grâce à des étiquettes nfc/qr -- Gérer l'attribution des équipements aux utilisateurs et aux projets/emplacements -- Automatiser la détection des entrées/sorties d'équipements via des points de scan -- Conserver l'historique de tous les mouvements et utilisations -- Fournir des analyses et statistiques d'utilisation avancées -- Offrir une solution adaptable à différents secteurs d'activité - -## 3. Besoins fonctionnels détaillés - -### 3.1 Gestion multi-organisations - -- Support de plusieurs organisations clientes avec isolation complète des données -- Paramétrage par organisation (terminologie, champs personnalisés, flux de travail) -- Gestion des rôles et permissions par organisation - -### 3.2 Gestion des équipements - -- Inventaire complet avec informations détaillées : - - Référence unique et code NFC/qr associé - - Nom et description - - Date d'acquisition et valeur - - État et niveau d'usure - - Spécifications techniques (type, marque, modèle, etc.) - - Catégorie de rattachement - - Champs personnalisables selon le secteur d'activité -- Création, modification et suppression d'équipements -- Association d'un équipement à une catégorie spécifique -- Support pour documentation technique, photos et fichiers associés -- Gestion des maintenances préventives et curatives - -### 3.3 Suivi automatisé par NFC // ou SCAN QR Code - -- Intégration avec des étiquettes nfc/qr à faible coût / ou équivalent -- Points de scan aux entrées/sorties des zones de stockage -- Scan mobile via smartphones/tablettes pour vérification terrain -- Détection automatique des mouvements d'équipements -- Alertes en cas de sortie non autorisée - - mail - - sms - - alerte perso -- Cartographie des dernières localisations connues - -### 3.4 Gestion des affectations - -- Attribution d'équipements à : - - Un utilisateur/employé - - Un projet/chantier - - Un emplacement physique -- Enregistrement des dates de début et fin d'affectation -- Affectation groupée de plusieurs équipements simultanément -- Workflows d'approbation configurables -- Historique complet des affectations - -### 3.5 Gestion des utilisateurs - -- Enregistrement des informations sur les utilisateurs : - - Profil complet (nom, prénom, contact, etc.) - - Rôle et permissions dans le système - - Département/équipe de rattachement -- Suivi des équipements attribués à chaque utilisateur -- Gestion des accès par niveau de permission - -### 3.6 Gestion des projets/emplacements/chantiers - -- Structure flexible adaptable selon les besoins : - - Projets temporaires avec dates de début/fin - - Emplacements physiques permanents - - Zones géographiques -- Hiérarchisation possible (bâtiment > étage > pièce) -- Géolocalisation et cartographie -- Suivi des équipements affectés - -### 3.7 Catégorisation des équipements - -- Système de catégories et sous-catégories multiniveau -- Attributs spécifiques par catégorie d'équipement -- Système de préfixage automatique des références -- Organisation logique adaptée au secteur d'activité - -### 3.8 Analyses et statistiques avancées - -- Dashboard personnalisable avec indicateurs clés -- Rapports sur les taux d'utilisation des équipements -- Analyses prédictives pour planification des besoins -- Alertes sur équipements sous-utilisés ou sur-utilisés -- Statistiques par utilisateur, projet, catégorie et équipement -- Rapports exportables dans différents formats - -### 3.9 Intégration et API - -- API REST complète pour intégration avec d'autres systèmes -- Intégration possible avec des ERP, GMAO, ou logiciels comptables -- Export/import de données en différents formats -- Webhooks pour événements système - -## 4. Description fonctionnelle détaillée - -### 4.1 Structure générale - -- Interface responsive accessible sur tous supports -- Cinq modules principaux : Utilisateurs, Projets/Emplacements, Catégories, Équipements, Affectations -- Navigation intuitive avec accès contextuel aux fonctionnalités -- Dashboard personnalisable par type d'utilisateur - -### 4.2 Module de gestion des utilisateurs - -- Annuaire complet avec recherche avancée et filtres -- Gestion des profils avec historique d'activité -- Vue des équipements actuellement affectés -- Statistiques d'utilisation et de responsabilité matérielle -- Système de notification personnalisable - -### 4.3 Module de gestion des projets/emplacements - -- Structure adaptable selon le secteur d'activité -- Visualisation des équipements actuellement présents -- Timeline d'occupation des ressources -- Planification des besoins futurs -- Cartographie des emplacements physiques - -### 4.4 Module de gestion des catégories - -- Arborescence des catégories personnalisable -- Gestion des attributs spécifiques par catégorie -- Règles de nommage et d'attribution automatisées -- Templates pour accélérer la création d'équipements similaires -- Rapports analytiques par catégorie - -### 4.5 Module de gestion des équipements - -- Interface complète de gestion d'inventaire -- Fiche détaillée avec historique complet de chaque équipement -- Journal d'activité avec tous les mouvements et scans nfc/qr -- Suivi du cycle de vie (de l'acquisition à la mise au rebut) -- Planning de maintenance préventive -- Système d'alerte pour maintenance ou certification à renouveler - -### 4.6 Module de gestion des affectations - -- Processus guidé d'affectation avec validation -- Scan nfc/qr pour confirmation de prise en charge -- Vue calendaire des disponibilités -- Système de réservation anticipée -- Alertes de retour pour affectations arrivant à échéance -- Workflows configurables avec approbations multi-niveaux - -### 4.7 Fonctionnalités de recherche avancée - -- Recherche globale intelligente sur tous les critères -- Filtres contextuels et sauvegarde de recherches favorites -- Recherche par scan nfc/qr pour identification rapide -- Suggestions intelligentes basées sur l'historique - -### 4.8 Module d'administration et paramétrage - -- Configuration complète adaptée à chaque organisation -- Personnalisation de la terminologie et des champs -- Gestion des droits et rôles utilisateurs -- Audit logs pour toutes les actions système -- Paramétrage des notifications et alertes - -## 5. Interactions et automatisations - -### 5.1 Workflow de scan nfc/qr - -- Scan à l'entrée/sortie des zones de stockage -- Mise à jour automatique de la localisation -- Vérification de la légitimité du mouvement -- Création automatique d'affectation sur scan sortant -- Clôture automatique d'affectation sur scan entrant - -### 5.2 Interactions entre équipements - -- Gestion des relations parent/enfant entre équipements -- Suivi des assemblages/désassemblages -- Alertes sur incompatibilités potentielles -- Recommandations d'équipements complémentaires - -### 5.3 Automatisation des processus - -- Rappels automatiques pour retours d'équipements -- Alertes de maintenance basées sur l'utilisation réelle -- Détection d'anomalies dans les patterns d'utilisation -- Suggestions d'optimisation du parc - -Ce cahier des charges est destiné à servir de référence pour le développement d'une plateforme SaaS de gestion d'équipements adaptable à différents secteurs d'activité, avec un accent particulier sur l'automatisation via technologie nfc/qr et l'analyse avancée des données. - ----- -docs-and-prompts/market/tunnel-conversion.md -# Tunnel de Conversion ForTooling - Phase de Lancement - -## 1. Structure du Tunnel de Vente - -### Phase 1: Attraction (Acquisition) - -- **Objectif**: Attirer des prospects qualifiés vers la landing page -- **Canaux prioritaires**: Google Ads, LinkedIn, référencement naturel -- **Message principal**: "Solution innovante pour suivre vos équipements BTP à prix mini" -- **KPI**: Coût par clic qualifié, taux de rebond initial - -### Phase 2: Intérêt (Landing Page) - -- **Objectif**: Capter l'attention et démontrer la compréhension du problème -- **Méthode**: Hero section impactante + section problème/solution -- **Message clé**: "Fini les pertes d'équipements et le temps perdu à chercher" -- **KPI**: Taux de scroll, temps sur page - -### Phase 3: Considération (Démonstration Valeur) - -- **Objectif**: Prouver l'efficacité et le ROI de la solution -- **Méthode**: Section "Comment ça marche" + avantages + simulateur d'économies -- **Message clé**: "Simple, rapide et jusqu'à 70% moins cher que les alternatives" -- **KPI**: Interactions avec simulateur, vidéos vues - -### Phase 4: Conversion (Essai Gratuit) - -- **Objectif**: Inciter à l'essai gratuit de 14 jours -- **Méthode**: Offre spéciale lancement + formulaire simplifié + garanties -- **Message clé**: "Essayez sans risque pendant 14 jours - Programme pionnier" -- **KPI**: Taux de conversion vers essai gratuit - -### Phase 5: Onboarding (Post-Conversion) - -- **Objectif**: Maximiser l'adoption et l'usage pendant l'essai -- **Méthode**: Email séquentiels + appel de bienvenue + guide démarrage -- **Message clé**: "Voyez des résultats concrets en seulement quelques jours" -- **KPI**: Taux d'activation, % utilisation des fonctionnalités clés - -### Phase 6: Conversion finale (Devenir client) - -- **Objectif**: Transformer l'essai en abonnement payant -- **Méthode**: Démonstration ROI déjà réalisé + offre spéciale fin d'essai -- **Message clé**: "Continuez à économiser avec notre offre spéciale pionnier" -- **KPI**: Taux de conversion essai → client payant - -## 2. Optimisation du Formulaire d'Essai Gratuit - -### Principes clés - -- **Minimalisme**: Demander uniquement l'information essentielle -- **Étapes**: Limiter à une seule étape si possible (max 2) -- **Valeur perçue**: Mettre en avant ce qu'ils obtiennent immédiatement -- **Réduction des frictions**: Éliminer tout obstacle à la complétion - -### Informations à collecter (par ordre de priorité) - -1. Email professionnel (obligatoire) -2. Numéro de téléphone (obligatoire - crucial pour suivi) -3. Nom de l'entreprise (obligatoire) -4. Taille approximative du parc d'équipements (optionnel mais utile) - -### Éléments de réassurance - -- "Sans carte bancaire" -- "Configuration en 48h" -- "Données sécurisées et confidentielles" -- "Annulation en 1 clic" - -## 3. Séquence Emails Post-Inscription - -### Email 1: Confirmation immédiate - -- **Objet**: "Bienvenue dans l'aventure ForTooling! Voici la suite..." -- **Contenu**: Confirmation + prochaines étapes + calendrier rendez-vous onboarding -- **CTA**: "Planifier mon appel de démarrage rapide (15min)" - -### Email 2: J+1 - Guide de démarrage - -- **Objet**: "Votre guide étape par étape pour démarrer avec ForTooling" -- **Contenu**: PDF guide démarrage + vidéo courte + FAQ initiale -- **CTA**: "Voir la vidéo de démarrage (3min)" - -### Email 3: J+3 - Première vérification - -- **Objet**: "Avez-vous rencontré des difficultés avec ForTooling?" -- **Contenu**: Check-in + astuces clés + proposition d'aide -- **CTA**: "Répondre pour obtenir de l'aide" ou "Tout va bien!" - -### Email 4: J+7 - Milestone et fonctionnalités avancées - -- **Objet**: "Découvrez ces 3 fonctionnalités qui vous feront gagner du temps" -- **Contenu**: Fonctionnalités avancées + témoignage + astuce pro -- **CTA**: "Activer ces fonctionnalités" - -### Email 5: J+10 - Partage de cas d'usage - -- **Objet**: "Comment les entreprises BTP utilisent ForTooling (exemples concrets)" -- **Contenu**: Cas d'usage + scénarios + bonnes pratiques -- **CTA**: "Appliquer ces méthodes à votre entreprise" - -### Email 6: J+12 - Préparation fin d'essai - -- **Objet**: "Votre essai ForTooling se termine dans 2 jours - Voici votre offre spéciale" -- **Contenu**: Récapitulatif valeur + offre exclusive + procédure simple -- **CTA**: "Activer mon offre spéciale pionniers (-50%)" - -### Email 7: J+14 - Dernier jour - -- **Objet**: "DERNIER JOUR - Votre décision concernant ForTooling" -- **Contenu**: Options disponibles + rappel bénéfices + témoignages -- **CTA**: "Continuer avec ForTooling" ou "Planifier un dernier appel" - -### Email 8: J+15 - Récupération (si pas converti) - -- **Objet**: "Nous respectons votre décision, mais avant de nous quitter..." -- **Contenu**: Sondage court + offre dernière chance + possibilité extension -- **CTA**: "Bénéficier d'une semaine supplémentaire d'essai" - -## 4. Script d'Appel de Bienvenue - -### Objectif de l'appel - -Établir une relation, comprendre les besoins spécifiques, assurer le bon démarrage - -### Introduction (1min) - -"Bonjour [Prénom], merci d'avoir démarré votre essai de ForTooling! Je m'appelle [Votre nom] et je suis là pour m'assurer que vous puissiez tirer le maximum de votre période d'essai. Avez-vous quelques minutes pour que nous parlions de vos besoins spécifiques?" - -### Questions clés (5min) - -1. "Pouvez-vous me parler brièvement des défis que vous rencontrez actuellement avec la gestion de vos équipements?" -2. "Environ combien d'équipements souhaitez-vous suivre avec ForTooling?" -3. "Avez-vous déjà utilisé une solution similaire par le passé?" -4. "Qu'est-ce qui vous a incité à essayer ForTooling spécifiquement?" - -### Présentation personnalisée (5min) - -"D'après ce que vous me dites, je pense que ces fonctionnalités spécifiques pourraient vous être particulièrement utiles..." (adapter selon réponses) - -### Plan de démarrage (3min) - -"Voici ce que je vous propose comme plan pour ces 14 jours d'essai: - -1. Aujourd'hui/demain: Configuration initiale de votre compte -2. D'ici la fin de semaine: Étiquetage de vos premiers équipements (10-20) -3. Début semaine prochaine: Formation rapide de vos équipes (15min max) -4. Milieu de semaine prochaine: Premier bilan d'utilisation avec moi - Cela vous semble-t-il réalisable?" - -### Conclusion et prochaines étapes (1min) - -"Super! Je vais vous envoyer un récapitulatif par email. N'hésitez pas à me contacter directement à ce numéro si vous avez la moindre question. Notre objectif est que vous puissiez voir des résultats concrets avant la fin de votre période d'essai." - -## 5. Stratégie de Relance Fin d'Essai - -### Principes - -- Approche consultative plutôt que pression commerciale -- Focus sur valeur déjà obtenue pendant l'essai -- Offre spéciale avec délai limité - -### Timing des relances - -- J-3: Email préparatoire -- J-1: Relance téléphonique -- J+0: Email "dernier jour" -- J+1: Appel de récupération si non converti - -### Script d'appel J-1 - -"Bonjour [Prénom], c'est [Votre nom] de ForTooling. Je vous appelle car votre période d'essai se termine demain, et je voulais faire un point avec vous: - -1. Comment s'est passée votre expérience jusqu'à présent? -2. Avez-vous pu observer des améliorations dans la gestion de vos équipements? -3. Y a-t-il des questions ou préoccupations qui pourraient vous empêcher de continuer? - -Comme vous faites partie de nos premiers utilisateurs, nous avons une offre spéciale "Pionnier": -50% sur votre abonnement première année, ce qui ramène le coût à seulement [X]€ par mois. - -Souhaitez-vous bénéficier de cette offre pour continuer avec ForTooling?" - -## 6. Tactiques de Réduction des Abandons - -### Identifiez les signes d'alerte précoces - -- Non-connexion après 3 jours -- Moins de 5 équipements enregistrés -- Absence de scans après configuration - -### Actions préventives - -- Email personnalisé: "Besoin d'aide pour démarrer?" -- Appel proactif: "Puis-je vous aider avec la mise en place?" -- Offre d'extension: "Besoin de plus de temps? Essayez 7 jours supplémentaires" - -### Incitatifs de rétention - -- Débloquer fonctionnalité premium pendant l'essai -- Offrir configuration gratuite des 20 premiers équipements -- Proposer session de formation équipe offerte - -### Feedback sur les abandons - -- Sondage court et simple -- Appel de suivi non-commercial -- Offre de retour facilitée (données conservées 30 jours) - ----- -docs-and-prompts/market/strategie-marketing-honnete.md -# Stratégie Marketing ForTooling - Phase de Lancement - -## Positionnement Stratégique pour une Nouvelle Solution - -### USP (Unique Selling Proposition) - -"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." - -### Points de différenciation clés (Factuel et vérifiable) - -- **Prix ultra-compétitif** (50-70% moins cher que les options établies) -- **Zéro matériel coûteux** (QR codes/NFC vs balises GPS onéreuses) -- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) -- **Mise en place en moins de 48h** (vs semaines pour solutions traditionnelles) -- **ROI rapide et mesurable** (diminution des pertes, gain de temps) - -### Persona cibles prioritaires - -1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) -2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) -3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) - -## Avantages du Statut de Nouvelle Entreprise - -### Transformer votre nouveauté en force - -- **Agilité et réactivité**: Adaptation rapide aux besoins spécifiques des premiers clients -- **Support personnalisé**: Attention particulière aux premiers utilisateurs -- **Influence sur le développement**: Participation à l'évolution du produit -- **Conditions préférentielles**: Avantages exclusifs pour les premiers adoptants - -### Programme "Pionniers ForTooling" - -- Réduction tarifaire substantielle pour les 20 premiers clients -- Support direct avec les fondateurs/développeurs -- Mise en avant future (avec accord) comme partenaires de la première heure -- Webinaires exclusifs et rencontres networking - -## Utilisation Stratégique des Données Sectorielles - -### Statistiques BTP exploitables (à sourcer) - -- Taux moyen de perte d'équipements dans le secteur (15-20% annuel) -- Coût moyen du remplacement de matériel (X€/an pour une PME moyenne) -- Temps quotidien perdu à rechercher du matériel (20-30 min/personne/jour) -- Impact financier des retards de chantier liés aux problèmes d'équipement - -### Calculs de ROI à mettre en avant - -- Simulateur d'économies basé sur taille de l'entreprise et parc d'équipement -- Coût réel des pertes vs investissement ForTooling -- Valorisation du temps gagné en recherche de matériel -- Économies liées à la prolongation de la durée de vie des équipements - -## Approche Content Marketing Adaptée - -### Contenu de valeur à créer en priorité - -- Guide: "Comment réduire les pertes de matériel sur vos chantiers" -- Ebook: "Les coûts cachés d'une mauvaise gestion d'équipements" -- Calculateur: "Estimez vos pertes annuelles d'équipements" -- Checklist: "10 bonnes pratiques pour augmenter la durée de vie de votre matériel" - -### Partenariats de contenu stratégiques - -- Collaboration avec médias BTP pour articles d'expertise -- Interviews de dirigeants et experts du secteur sur leurs problématiques -- Webinaires co-organisés avec fournisseurs d'équipements -- Présence sur salons professionnels avec offre spéciale salon - -## Stratégie d'Acquisition Adaptée aux Débuts - -### Canaux prioritaires - -- LinkedIn: ciblage précis des décideurs BTP -- Google Ads: mots-clés spécifiques à forte intention -- Démarchage direct: approche personnalisée des premiers clients -- Réseaux d'entrepreneurs et associations BTP - -### Tactiques d'acquisition créatives - -- "Test Challenge": Essai comparatif de ForTooling vs méthode actuelle pendant 14 jours -- Démonstrations in situ sur petits parcs d'équipements -- Programme parrainage avant même le lancement -- Offres groupées pour fédérations/groupements d'entreprises BTP - ----- -docs-and-prompts/market/strategie-marketing-fortooling.md -# Stratégie Marketing et Tunnel de Conversion ForTooling - -## 1. Positionnement Stratégique - -### 1.1 Unique Selling Proposition (USP) - -"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." - -### 1.2 Points de différenciation clés - -- **Prix ultra-compétitif** (50-70% moins cher que les concurrents) -- **Zéro matériel coûteux** (utilisation de QR codes/NFC vs balises GPS onéreuses) -- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) -- **Mise en place en moins de 48h** (vs semaines pour solutions concurrentes) -- **ROI immédiat et mesurable** (diminution des pertes, gain de temps) - -### 1.3 Persona cibles prioritaires - -1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) -2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) -3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) - -## 2. Architecture du Tunnel de Conversion - -### 2.1 Étape 1: Attraction (Top du Funnel) - -- **SEO ciblé** sur requêtes problématiques ("perte matériel chantier", "gestion outillage BTP") -- **Google Ads** sur mots-clés transactionnels à fort intent -- **Posts LinkedIn** ciblant les décideurs BTP (format statistiques choc + solution) -- **Publicité dans médias spécialisés BTP** (print et digital) - -### 2.2 Étape 2: Intérêt (Landing Page) - -- **Hero section impactante**: - - - Headline: "Fini les pertes de matériel: suivez tous vos équipements BTP pour 1,90€ par jour" - - Sous-title: "Solution simple par QR code - Mise en place en 48h - Sans engagement" - - Démonstration vidéo courte (30s) montrant la simplicité d'utilisation - - CTA principal: "ESSAI GRATUIT 14 JOURS" (en orange, contrasté) - - Preuve sociale: "Déjà +3000 équipements suivis dans 47 entreprises BTP" - -- **Section problème-solution immédiate** (priorité #1): - | PROBLÈME | NOTRE SOLUTION | BÉNÉFICE CHIFFRÉ | - |----------|----------------|------------------| - | 15-20% des équipements perdus chaque année | Localisation instantanée par QR code | Économie de 5 000-15 000€/an | - | 30 min/jour perdues à chercher du matériel | Inventaire accessible en 3 clics | Gain de 125h/an/employé | - | Attribution floue et déresponsabilisation | Traçabilité complète par utilisateur | -70% d'équipements non retournés | - | Solutions concurrentes à 5-10K€ | Prix fixe ultra-compétitif | ROI dès le premier mois | - -### 2.3 Étape 3: Considération (Mid-Funnel) - -- **Démonstration du fonctionnement** (3 étapes ultra-simples): - - 1. **ÉTIQUETEZ** vos équipements avec nos QR codes ultra-résistants - 2. **SCANNEZ** pour attribuer ou déplacer (3 secondes par scan) - 3. **CONTRÔLEZ** votre parc complet depuis le dashboard - -- **Section témoignages** avec métriques précises: - - - "Nous avons réduit nos pertes d'équipements de 83% en 3 mois" - Martin D., Directeur, MTP Construction - - "Économie de 12 500€ la première année et gain de temps quotidien" - Sophie L., Resp. Logistique, BatiPro - - Inclure photos, logos d'entreprises et postes spécifiques - -- **Social proof renforcée**: - - Compteur en temps réel d'équipements suivis - - Logos clients (avec autorisations) - - Notation clients (4.8/5 basée sur X avis) - -### 2.4 Étape 4: Conversion (Bottom Funnel) - -- **Pricing stratégique**: - - - Afficher tarifs en "par jour" plutôt qu'en mensuel (perception de coût moindre) - - Proposer 3 formules avec celle du milieu pré-sélectionnée (technique d'ancrage) - - Comparer avec le "coût de ne rien faire" (pertes annuelles moyennes: 7500€) - - Garantie "satisfait ou remboursé 30 jours" (réduction du risque perçu) - -- **CTA d'essai gratuit omniprésent**: - - Formulaire d'inscription ultra-simplifié (email + téléphone uniquement) - - "Commencez en 2 minutes - Sans carte bancaire" - - Décompte de temps limité: "Offre spéciale: -20% les 3 premiers mois si vous vous inscrivez aujourd'hui" - -### 2.5 Étape 5: Onboarding (Post-conversion) - -- **Séquence email automatisée**: - - - J1: Guide de démarrage rapide + vidéo personnalisée - - J3: Check-in "Besoin d'aide?" + cas d'usage clés - - J7: Partage de succès clients similaires - - J10: Invitation démonstration personnalisée - - J12: Rappel fin d'essai + témoignages résultats - - J14: Offre spéciale première année + formulaire CB - -- **Relance téléphonique stratégique**: - - Appel à J5: "Comment se passe votre essai? Des questions?" - - Appel à J13: "Prêt à continuer? Offre spéciale réservée pour vous" - -## 3. Optimisation SEO Stratégique - -### 3.1 Mots-clés prioritaires - -- **Intention transactionnelle forte**: - - - "logiciel gestion équipement BTP" - - "suivi matériel chantier QR code" - - "solution traçabilité outils construction" - - "gestion inventaire entreprise BTP" - -- **Intention informationnelle** (content marketing): - - "comment réduire pertes matériel chantier" - - "coût perte équipement construction" - - "responsabilisation équipe BTP" - - "ROI gestion parc équipements" - -### 3.2 Structure de contenu SEO - -- **Pages de landing spécifiques par problématique**: - - - /reduction-pertes-materiels-chantier - - /suivi-outils-qr-code - - /gestion-attribution-equipements-btp - - /economie-gestion-materiel-construction - -- **Blog optimisé** (minimum 2 articles/mois): - - "Comment cette entreprise a économisé 15 000€ en réduisant ses pertes de matériel" - - "Guide: Calculez ce que vous coûtent vraiment vos pertes d'équipements" - - "5 techniques pour responsabiliser vos équipes sur le matériel" - - "Étude de cas: De l'Excel à ForTooling - Transformation digitale d'un parc matériel" - -### 3.3 Optimisations techniques - -- **Schema.org markup** pour: - - - Témoignages (Review Schema) - - Tarifs (Offer Schema) - - FAQ (FAQPage Schema) - - Organisation (Organization Schema) - -- **Core Web Vitals** optimisés pour mobile: - - LCP < 2.5s (images optimisées, serveur rapide) - - FID < 100ms (JavaScript non-bloquant) - - CLS < 0.1 (layout stable, fonts préchargées) - -## 4. Conversion Rate Optimization (CRO) - -### 4.1 Tests A/B prioritaires - -1. **Hero Section**: - - - Headline axé problème vs headline axé solution - - CTA "Essai gratuit" vs "Voir la démo en 2 min" - - Vidéo autoplay vs image statique - -2. **Formulaire de conversion**: - - - Minimal (email uniquement) vs standard (email + téléphone) - - Pop-up vs inline - - Avec/sans countdown timer - -3. **Preuve sociale**: - - Logos clients vs témoignages détaillés - - Statistiques chiffrées vs histoires de réussite - - Placement haut vs bas de page - -### 4.2 Micro-conversions à tracker - -- Pourcentage de scroll (≥70% = intent) -- Temps passé sur page (≥2min = intent) -- Clics sur témoignages (fort intent) -- Visionnage vidéo démo (fort intent) -- Ouverture FAQ (intent modéré) - -### 4.3 Objections à lever explicitement - -- **Objection prix**: "Plus abordable qu'un seul équipement perdu par mois" -- **Objection complexité**: "Prise en main en moins de 5 minutes, même sans compétence technique" -- **Objection temps**: "Déploiement en 48h sans perturber votre activité" -- **Objection internet**: "Fonctionne hors-ligne sur les chantiers isolés" -- **Objection engagement**: "Sans engagement - Résiliable à tout moment" - -## 5. Éléments Visuels Marketing Stratégiques - -### 5.1 Images à fort impact - -- **Avant/Après visuel**: Chaos d'équipements vs organisation parfaite -- **ROI visualisé**: Graphique économies réalisées vs coût solution -- **Contexte réel**: Photos sur chantiers authentiques, pas de stock photos -- **Process simplifié**: Infographie 3 étapes (étiqueter → scanner → contrôler) - -### 5.2 Vidéos persuasives - -- **Démo ultra-courte** (30s) en autoplay sans son: scan → dashboard → localisation -- **Témoignage client** (1min): problème → solution → résultats mesurables -- **Explication technique** (2min): pour rassurer décideurs techniques - -### 5.3 Confiance et crédibilité - -- **Badges sécurité/RGPD**: conformité, sécurité des données -- **Logos partenaires/clients**: reconnaissance par l'écosystème -- **Certifications**: labels qualité, innovation -- **Médias**: mentions presse spécialisée BTP - -## 6. Tactiques de Growth Hacking - -### 6.1 Acquisition non-conventionnelle - -- **Partenariats fournisseurs BTP**: offre groupée avec vendeurs d'équipements -- **Programme ambassadeur**: commission pour chaque entreprise référée -- **Webinaires ciblés**: "Comment réduire vos pertes d'équipements de 70% en 30 jours" -- **Défi gratuit**: "Testez pendant 14 jours et mesurez vos économies - Résultats garantis" - -### 6.2 Rétention optimisée - -- **Gamification**: score "d'efficacité matériel" comparé à la moyenne du secteur -- **Alertes ROI**: notifications des économies réalisées -- **Check-in trimestriel**: rapport personnalisé d'optimisation avec consultant -- **Communauté**: groupe privé d'échange entre responsables matériel - -### 6.3 Referral Engine - -- **Programme "Parrainez un artisan"**: 2 mois offerts pour chaque référence -- **Co-marketing**: témoignages clients en échange de visibilité -- **Contenu co-créé**: études de cas détaillées avec clients ambassadeurs - -## 7. Plan d'Implémentation Prioritaire - -### 7.1 Actions immédiates (J+0 à J+30) - -1. Refonte de la landing page avec structure de conversion optimisée -2. Mise en place des tunnels d'emails automatisés pré/post essai -3. Création de 3 témoignages clients détaillés (vidéo + texte) -4. Configuration tracking analytics conversion (objectifs GA4/Meta) -5. Lancement campagne Google Ads sur mots-clés prioritaires - -### 7.2 Seconde phase (J+30 à J+90) - -1. Développement de 5 articles de blog optimisés SEO -2. Création landing pages spécifiques par problématique -3. Mise en place programme de parrainage client -4. Lancement tests A/B principaux (headline, CTA, formulaire) -5. Développement automatisation relances essais gratuits - -### 7.3 KPIs critiques à suivre - -- Taux de conversion visiteur → essai gratuit (objectif: >5%) -- Taux de conversion essai → client payant (objectif: >30%) -- CAC (Coût d'Acquisition Client) (objectif: <3 mois de revenu) -- LTV (Lifetime Value) (objectif: >24 mois) -- Taux de churn mensuel (objectif: <3%) - -## 8. Messages Persuasifs Clés (Copywriting) - -### 8.1 Headlines A/B testés - -- "Stop aux 7500€ perdus chaque année en équipements égarés sur vos chantiers" -- "Suivez 100% de vos équipements BTP pour moins de 2€ par jour - Sans matériel coûteux" -- "Vos outils toujours localisés, vos équipes responsabilisées, votre budget préservé" -- "Cette solution QR code a permis à 47 entreprises BTP d'économiser 350 000€ de matériel" - -### 8.2 Éléments de friction à éliminer - -- Formulaire trop long (réduire au strict minimum) -- Jargon technique (simplifier le langage) -- Prix mensuel (préférer affichage quotidien ou annuel avec économies) -- Étapes multiples (réduire au maximum les clics vers conversion) - -### 8.3 Modificateurs de valeur perçue - -- Calcul personnalisé des économies potentielles -- Comparatif direct avec solutions concurrentes -- Démonstration du temps économisé (convertir en euros) -- Garantie "Satisfait ou Remboursé" proéminente - ---- - -**RAPPEL STRATÉGIQUE**: L'objectif principal n'est pas de "vendre" mais de convaincre d'essayer le produit gratuitement pendant 14 jours. La véritable conversion s'effectuera grâce à l'expérience produit elle-même et au processus d'onboarding soigneusement orchestré. - ----- -docs-and-prompts/market/pages-techniques-parcours.md -# Pages Techniques et Parcours Utilisateur - -## Pages Techniques et Légales - -### Pages Légales Essentielles - -1. **CGV/CGU** - - - Conditions claires et transparentes - - Langage accessible (éviter jargon juridique excessif) - - Sections bien structurées par thème - - Date de dernière mise à jour visible - -2. **Politique de confidentialité (RGPD)** - - - Données collectées et finalités - - Conservation et protection des données - - Droits des utilisateurs - - Utilisation des cookies - - Procédures de demande d'accès/suppression - -3. **Mentions légales** - - - Informations société - - Hébergement - - Directeur de publication - - Propriété intellectuelle - - Limitations de responsabilité - -4. **Conditions d'utilisation du service** - - Droits d'utilisation - - Restrictions d'usage - - Garanties et limites - - Résiliation et suspension - - Support et maintenance - -### Pages Techniques à Développer - -1. **Sécurité des données** - - - Architecture sécurisée - - Chiffrement et protection - - Sauvegardes et redondance - - Conformité RGPD - - Tests de sécurité réguliers - -2. **API et intégrations** - - - Documentation API (même basique pour le futur) - - Intégrations existantes ou prévues - - Procédure de demande d'accès API - - Cas d'usage d'intégration - - Support développeurs - -3. **Guide utilisateur/Centre d'aide** - - Navigation par rôle utilisateur - - Recherche intégrée - - Articles base de connaissances - - Vidéos tutoriels courtes - - FAQ technique détaillée - -## Optimisation des Parcours Utilisateur - -### Parcours d'Onboarding - -1. **Page "Premiers pas avec ForTooling"** - - - Guide visuel étape par étape - - Vidéo d'introduction (2-3 min) - - Checklist interactive de démarrage - - Jalons d'activation clairs - - Contact support dédié nouvel utilisateur - -2. **Guides spécifiques par profil utilisateur** - - - Pour administrateurs système - - Pour responsables matériel - - Pour utilisateurs terrain - - Pour chefs de chantier/projet - - Pour direction/décideurs (rapports) - -3. **Vidéos d'initiation courtes** - - - Série "Démarrer en 10 minutes" - - Tutoriels ciblés par fonctionnalité (1-2 min) - - Démos cas d'usage courants - - Astuces et raccourcis - - Questions fréquentes visuelles - -4. **Checklist de démarrage** - - Étapes essentielles séquentielles - - Indicateurs de progression - - Validation des étapes complétées - - Contenus d'aide contextuelle - - Célébration des succès d'activation - -### Programme Partenaires/Affiliés - -1. **Page Programme Ambassadeur** - - - Présentation des avantages - - Fonctionnement de la commission - - Témoignages partenaires (une fois existants) - - Outils marketing fournis - - FAQ programme partenaire - -2. **Commission référencement** - - - Grille de commission transparente - - Processus de tracking des leads - - Conditions de paiement - - Tableau de bord partenaire - - Support dédié partenaires - -3. **Processus d'inscription** - - - Critères d'éligibilité - - Formulaire de candidature - - Étapes de validation - - Formation initiale partenaire - - Kit de démarrage - -4. **Avantages et conditions** - - Avantages financiers détaillés - - Formations exclusives - - Accès anticipé nouvelles fonctionnalités - - Co-marketing opportunités - - Événements partenaires - -## Stratégie de Contenu par Phase - -### Phase 1 (Lancement - 3 premiers mois) - -1. Landing page principale -2. Pages Fonctionnalités, Tarifs, À propos -3. Page Comment ça marche -4. FAQ essentielle -5. Blog (3-5 articles initiaux) -6. Pages légales obligatoires - -**Priorité**: Conversion des premiers visiteurs en utilisateurs - -### Phase 2 (Développement - 3-6 mois) - -1. Pages sectorielles (2-3 premières) -2. Centre de ressources basique -3. Expansion du blog (1-2 articles/semaine) -4. Témoignages initiaux (dès premiers clients) -5. FAQ approfondie - -**Priorité**: SEO et création d'autorité dans le domaine - -### Phase 3 (Optimisation - 6-12 mois) - -1. Études de cas détaillées -2. Contenus avancés (webinaires, podcasts) -3. Pages partenaires et intégrations -4. Contenu généré par utilisateurs -5. Communauté utilisateurs - -**Priorité**: Rétention et expansion de l'écosystème - -## Recommandations pour Mise en Œuvre - -1. **Prioriser selon impact sur conversion**: - - - Landing page → Fonctionnalités → Tarifs → Comment ça marche - -2. **Créer une structure modulaire**: - - - Composants réutilisables (témoignages, CTA, avantages) - - Système de blocs cohérents - -3. **Maintenir cohérence visuelle et messagerie**: - - - Palette de couleurs consistante - - Mêmes messages clés sur toutes les pages - - Iconographie et illustrations harmonisées - -4. **Optimiser pour mobile en priorité**: - - - Interface simplifiée - - CTAs adaptés (plus grands sur mobile) - - Navigation intuitive - -5. **Intégrer mesure et analytics**: - - Événements de conversion sur chaque page - - Heatmaps sur pages critiques - - Tests A/B progressifs - ----- -docs-and-prompts/market/pages-support-conversion.md -# Pages de Support à la Conversion - -## Page "Comment ça marche" approfondie - -### Structure recommandée - -- Vidéo explicative (1-2 min) -- Processus détaillé en 5-7 étapes -- Zoom sur l'implémentation (48h) -- Témoignages d'experts sectoriels (si pas de clients, consultants BTP) -- FAQ spécifiques à l'implémentation -- CTA: "Voir une démo" + "Essai gratuit" - -### Processus à détailler - -1. **Inscription et configuration initiale** (15 min) - - - Création du compte entreprise - - Configuration des paramètres clés - - Personnalisation des catégories d'équipements - -2. **Import initial des équipements** (1-2h) - - - Upload de fichier Excel existant ou - - Saisie manuelle simplifiée ou - - Assistance à l'import par notre équipe - -3. **Étiquetage des équipements** (progressif) - - - Réception des QR codes résistants - - Application sur les équipements - - Scan initial de référencement - -4. **Formation des utilisateurs** (30 min) - - - Session de démonstration - - Guide pas-à-pas dans l'application - - Accès à des tutoriels vidéo - -5. **Déploiement terrain** (1-3 jours) - - - Premiers scans en conditions réelles - - Suivi des premières attributions - - Ajustement des processus si nécessaire - -6. **Optimisation continue** - - Analyse des premiers jours d'utilisation - - Recommandations personnalisées d'utilisation - - Ajout progressif d'équipements supplémentaires - -### Éléments visuels à inclure - -- Calendrier visuel du déploiement -- Screenshots étape par étape -- Exemples de QR codes et étiquettes -- Témoignages visuels de satisfaction - -## Page "FAQ" complète - -### Structure recommandée - -- Sections par thématique -- Questions organisées de générales à spécifiques -- Réponses concises mais complètes -- Liens vers pages détaillées -- CTA contextuel après chaque section - -### Catégories et questions essentielles - -#### Questions générales - -- Qu'est-ce que ForTooling exactement? -- Comment ForTooling se compare-t-il aux solutions existantes? -- Combien de temps pour être opérationnel? -- ForTooling est-il adapté à une petite entreprise? -- Puis-je essayer ForTooling avant de m'engager? - -#### Questions techniques - -- Les QR codes résistent-ils aux conditions de chantier? -- Que se passe-t-il si je n'ai pas de connexion sur le chantier? -- Quels appareils sont compatibles avec ForTooling? -- Les données sont-elles sécurisées? -- Puis-je exporter mes données facilement? - -#### Questions d'implémentation - -- Comment importer mon inventaire existant? -- Comment former mes équipes à l'utilisation? -- Combien de temps pour étiqueter tout mon matériel? -- Puis-je déployer progressivement la solution? -- Quel support recevrai-je pendant l'implémentation? - -#### Questions tarifaires - -- Y a-t-il des coûts cachés ou supplémentaires? -- Que comprend exactement chaque forfait? -- Puis-je changer de forfait en cours d'abonnement? -- Comment fonctionne la période d'essai? -- Offrez-vous des remises pour engagement annuel? - -#### Questions support et utilisation - -- Quel support est disponible en cas de problème? -- Proposez-vous des formations avancées? -- Comment suggérer de nouvelles fonctionnalités? -- Quelle est la disponibilité du service (uptime)? -- Comment contacter le support technique? - -## Page "Contact/Démo" - -### Structure recommandée - -- Options de contact (formulaire, email, téléphone) -- Planification de démo (calendrier Calendly) -- Processus de démonstration expliqué -- Formulaire contact intelligent (qualification leads) -- CTA secondaire: "Essai gratuit immédiat" - -### Formulaire de contact stratégique - -- Nom et prénom -- Email professionnel -- Téléphone (optionnel mais recommandé) -- Entreprise et fonction -- Taille de l'entreprise (dropdown) -- Nombre approximatif d'équipements à suivre -- Problématique principale (dropdown) -- Message personnalisé -- Préférence de contact (email, téléphone, visioconférence) - -### Section démo personnalisée - -- Titre: "Découvrez ForTooling en action sur vos cas d'usage" -- Explication: Démo personnalisée de 20 minutes -- Bénéfices: Focus sur vos besoins spécifiques -- Processus en 3 étapes (Prise de RDV → Préparation → Démonstration) -- Calendrier intégré pour réserver un créneau -- Témoignage sur qualité des démos - -### Informations de contact direct - -- Numéro de téléphone dédié -- Email de contact -- Horaires de disponibilité -- Temps de réponse moyen -- Chat en direct (si disponible) - ----- -docs-and-prompts/market/pages-seo-sectorielles.md -# Pages Stratégiques SEO et Contenu Sectoriel - -## Pages sectorielles/cas d'usage - -Ces pages ciblent des sous-segments spécifiques avec un contenu optimisé pour le référencement et la conversion. - -### 1. "ForTooling pour la maçonnerie" - -#### Structure recommandée - -- Introduction aux défis spécifiques de gestion d'équipements en maçonnerie -- Statistiques sectorielles (pertes d'outils, temps de recherche) -- Fonctionnalités ForTooling adaptées à la maçonnerie -- Catégories d'équipements pré-configurées -- Cas d'usage concrets (chantier type) -- Bénéfices chiffrés spécifiques -- Témoignage expert/consultant secteur (si pas encore de client) -- CTA sectoriel - -#### Mots-clés à cibler - -- gestion équipement maçonnerie -- suivi matériel chantier construction -- localisation outils maçons -- QR code suivi bétonnière/coffrage -- gestion prêt matériel maçonnerie - -#### Équipements spécifiques à mentionner - -- Bétonnières et malaxeurs -- Échafaudages et étais -- Outillage manuel spécifique -- Coffrages et banches -- Équipements de mesure et niveau - -### 2. "ForTooling pour l'électricité/plomberie" - -#### Structure recommandée - -- Introduction aux problématiques des artisans multi-sites -- Coût des outils spécialisés et impact des pertes -- Fonctionnalités ForTooling pour interventions multiples -- Gestion des équipements techniques coûteux -- Attribution aux techniciens itinérants -- Suivi et maintenance des outils de mesure -- ROI calculé pour artisan type -- CTA adapté - -#### Mots-clés à cibler - -- gestion outillage électricien -- suivi matériel plomberie -- attribution équipement techniciens -- traçabilité outils électroportatifs -- inventaire camion artisan - -#### Équipements spécifiques à mentionner - -- Outillage électroportatif -- Appareils de mesure et test -- Échelles et accès en hauteur -- Équipements de sécurité -- Stock véhicules d'intervention - -### 3. "ForTooling pour les locations de matériel" - -#### Structure recommandée - -- Introduction aux défis de la location d'équipements -- Problématique des retours et suivi -- Fonctionnalités de gestion entrées/sorties -- Traçabilité complète et historique -- Suivi état et maintenance des équipements -- Intégration facturation et gestion client -- Avantages compétitifs pour loueurs -- CTA spécifique location - -#### Mots-clés à cibler - -- gestion parc location BTP -- suivi retour équipements loués -- QR code location matériel -- logiciel gestion entrées sorties matériel -- traçabilité équipements location - -#### Fonctionnalités spécifiques à mettre en avant - -- Check-in/check-out rapide -- Suivi état avant/après location -- Historique par client/équipement -- Alertes retards de retour -- Planning disponibilité matériel - -### 4. "ForTooling pour les chefs de chantier" - -#### Structure recommandée - -- Introduction aux défis quotidiens des chefs de chantier -- Impact sur la productivité et planning -- Fonctionnalités d'allocation des ressources -- Visibilité en temps réel sur les équipements -- Planification besoins matériels par phase -- Responsabilisation des équipes -- Gain de temps quotidien estimé -- CTA orienté productivité - -#### Mots-clés à cibler - -- gestion équipement chef chantier -- planification ressources matérielles BTP -- disponibilité outils chantier -- responsabilisation équipe matériel -- optimisation utilisation équipements construction - -#### Avantages spécifiques à mettre en avant - -- Réduction temps recherche matériel -- Anticipation besoins par phase chantier -- Suivi utilisation par équipe/ouvrier -- Réduction conflits attribution matériel -- Meilleure planification ressources - -## Blog et Ressources - -### Catégories d'articles à développer - -1. **Guides pratiques** - - - Conseils d'optimisation de gestion d'équipements - - Tutoriels étape par étape - - Check-lists et processus - -2. **Études sectorielles** - - - Statistiques et tendances BTP - - Benchmarks et comparatifs - - Analyse coûts cachés - -3. **Conseils et meilleures pratiques** - - - Organisation et méthodes - - Responsabilisation des équipes - - Optimisation des processus - -4. **Innovations technologiques** - - - Nouveautés dans le BTP - - Technologies de traçabilité - - Digitalisation des chantiers - -5. **Témoignages et cas d'usage** - - Interviews experts - - Retours d'expérience - - Études de cas - -### Articles initiaux prioritaires - -1. **"Les coûts cachés d'une mauvaise gestion d'équipements BTP"** - - - Chiffrer les pertes financières réelles - - Impact sur productivité et délais - - Coûts indirects (recherche, remplacement, conflits) - - Solution et calcul ROI - -2. **"Comment réduire de 70% vos pertes de matériel sur chantier"** - - - Statistiques pertes secteur BTP - - Causes principales identifiées - - Méthodologie de réduction en 5 étapes - - Technologies facilitantes - -3. **"QR codes vs RFID vs GPS: quelle technologie de suivi pour vos équipements?"** - - - Comparatif détaillé des technologies - - Avantages/inconvénients de chaque solution - - Critères de choix selon besoins - - Analyse coûts/bénéfices - -4. **"5 indicateurs clés pour évaluer l'efficacité de votre gestion de parc matériel"** - - - KPIs essentiels à suivre - - Méthodes de calcul et benchmarks - - Outils de mesure recommandés - - Plan d'amélioration continu - -5. **"Guide: Comment mettre en place un système de traçabilité en 1 semaine"** - - Planification et préparation - - Étapes jour par jour - - Ressources nécessaires - - Conseils pour adoption rapide - -## Centre de Ressources - -### Types de contenus à proposer - -- **Guides téléchargeables (PDF)** - - - Guides approfondis et bien structurés - - Design professionnel avec illustrations - - Contenu actionnable et pratique - -- **Templates et calculateurs (Excel)** - - - Outils prêts à l'emploi - - Formules et automatisations utiles - - Instructions d'utilisation claires - -- **Checklists imprimables** - - - Format synthétique et pratique - - Points essentiels à vérifier - - Personnalisables par l'utilisateur - -- **Vidéos tutoriels** - - - Courtes (3-5 minutes maximum) - - Démonstrations pas-à-pas - - Sous-titrées et bien structurées - -- **Webinaires enregistrés** - - Présentations thématiques (30-45 min) - - Q&A incluses - - Slides téléchargeables - -### Ressources initiales prioritaires - -1. **"Guide ultime de la gestion d'équipements BTP" (ebook)** - - - 15-20 pages approfondies - - Illustrations et schémas - - Conseils pratiques et méthodologie - - Études de cas et exemples - -2. **"Calculateur ROI ForTooling" (spreadsheet interactif)** - - - Calculateur d'économies personnalisé - - Projection sur 1, 2 et 3 ans - - Comparaison avant/après - - Graphiques automatisés - -3. **"Checklist: Préparer votre migration vers un système digital"** - - - Liste de contrôle pré-migration - - Étapes essentielles chronologiques - - Points de vigilance et conseils - - Format imprimable A4 - -4. **"10 astuces pour maximiser la durée de vie de vos équipements"** - - Guide pratique maintenance préventive - - Conseils stockage et manipulation - - Fréquences d'entretien recommandées - - Estimation économies réalisables - ----- -docs-and-prompts/market/pages-essentielles.md -# Pages Essentielles à Développer pour ForTooling - -## Page "Fonctionnalités" détaillée - -### Structure recommandée - -- Introduction avec bénéfices globaux -- Sections par fonctionnalité majeure avec captures d'écran -- Comparaison discrète avec solutions concurrentes -- Cas d'usage par fonctionnalité -- CTA: "Essayer gratuitement" + "Demander une démo" - -### Éléments clés à inclure - -- **Module Inventaire** - - - Catalogage complet des équipements - - Catégorisation flexible adaptée au BTP - - Informations techniques et commerciales - - Gestion des cycles de vie (acquisition → maintenance → retrait) - -- **Module Suivi QR/NFC** - - - Processus de scan rapide (3 secondes) - - Localisation instantanée des équipements - - Historique complet des mouvements - - Fonctionnement hors-ligne sur chantier - -- **Module Attribution** - - - Assignation aux utilisateurs/équipes - - Attribution à des projets/chantiers - - Gestion des dates de retour prévues - - Alertes de non-retour automatiques - -- **Module Reporting** - - - Dashboard personnalisable - - Statistiques d'utilisation et disponibilité - - Calcul ROI et économies réalisées - - Exports PDF/Excel des rapports clés - -- **Fonctionnalités spéciales BTP** - - Étiquettes ultra-résistantes (poussière, eau, UV) - - Interface utilisable avec gants de chantier - - Vocabulaire et catégories pré-configurés BTP - - Champs personnalisés adaptés au secteur - -## Page "Tarifs" transparente - -### Structure recommandée - -- Introduction sur approche tarifaire (transparence, simplicité) -- 3 plans avec options clairement définies -- Comparaison des fonctionnalités par plan -- FAQ spécifiques aux prix -- CTA: "Démarrer avec [plan]" + "Contact commercial" - -### Plans tarifaires - -1. **Plan Essentiel** - - - Pour TPE/artisans (1-5 utilisateurs) - - Jusqu'à 100 équipements - - Fonctionnalités de base - - Prix: [X]€/mois ou [Y]€/jour - -2. **Plan Business** - - - Pour PME (5-20 utilisateurs) - - Jusqu'à 500 équipements - - Fonctionnalités avancées + rapports - - Prix: [X]€/mois ou [Y]€/jour - -3. **Plan Enterprise** - - Pour grandes entreprises (20+ utilisateurs) - - Équipements illimités - - Toutes fonctionnalités + personnalisation - - Prix: [X]€/mois ou [Y]€/jour - -### Avantages tarifaires à mettre en avant - -- Pas de coût matériel supplémentaire -- Économies réalisées vs pertes actuelles -- Prix par jour (perception moins coûteuse) -- Engagement flexible ou remise annuelle -- Offre spéciale de lancement (-50% premiers clients) - -## Page "À propos / Notre histoire" - -### Structure recommandée - -- Histoire de création (problème observé → solution) -- Mission et vision (démocratiser la gestion d'équipements BTP) -- Équipe (même petite, montrer les visages) -- Valeurs (simplicité, accessibilité, innovation) -- Programme Pionnier mis en avant -- CTA: "Rejoindre l'aventure ForTooling" - -### Éléments narratifs à développer - -- Origine de l'idée (expérience terrain BTP, observation problématiques) -- Approche différenciatrice (simplicité vs solutions complexes existantes) -- Vision du futur de la gestion d'équipements -- Parcours de développement du produit -- Ambitions et roadmap produit à venir - -### Éléments de confiance - -- Expertise combinée BTP et technologie -- Approche centrée sur problématiques terrain -- Témoignages experts/consultants sectoriels -- Mentions médias/partenaires (si disponibles) -- Engagement qualité et support réactif - ----- -docs-and-prompts/market/contenu-landing-page.md -# Contenu Optimisé pour Landing Page ForTooling - -## 1. Hero Section - -### Titre Principal (Options) - -- "Gérez enfin vos équipements BTP sans vous ruiner" -- "Suivez tous vos équipements BTP pour moins de 2€ par jour" -- "Solution innovante pour localiser votre matériel de chantier" - -### Sous-titre - -"Solution simple par QR code - Mise en place en 48h - Sans engagement" - -### CTA Principal - -"ESSAYEZ GRATUITEMENT PENDANT 14 JOURS" - -### Mention Offre de Lancement - -"OFFRE SPÉCIALE LANCEMENT: -50% pour nos 20 premiers clients + implémentation offerte" - -### Visuel Stratégique - -Image/vidéo montrant la solution en action sur un chantier réel - -- Scan QR code sur un équipement -- Vue du dashboard avec localisation -- Interface mobile en situation de chantier - -## 2. Section Problème-Solution - -### Introduction - -"Les entreprises du BTP font face à des défis quotidiens dans la gestion de leur matériel. ForTooling apporte une solution simple et abordable." - -### Tableau Problème-Solution - -| PROBLÈME DANS LE SECTEUR | NOTRE SOLUTION | BÉNÉFICE POTENTIEL | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------- | -| Les entreprises BTP perdent en moyenne 15-20% de leurs équipements chaque année\* | Localisation instantanée par QR code et historique complet des mouvements | Réduction drastique des pertes et vols d'équipements | -| Jusqu'à 30 minutes par jour perdues à chercher du matériel sur les chantiers\* | Inventaire accessible en 3 clics avec localisation précise | Gain de temps quotidien pour vos équipes | -| Attribution floue menant à la déresponsabilisation | Traçabilité complète par utilisateur et notifications de non-retour | Responsabilisation des équipes et meilleur soin du matériel | -| Solutions traditionnelles complexes et onéreuses (5-10K€) | Prix fixe ultra-compétitif sans matériel coûteux | ROI rapide et budget maîtrisé | - -\*Selon étude sectorielle BTP Magazine 2023 - -## 3. Comment Ça Marche - -### Étape 1: ÉTIQUETEZ - -"Appliquez nos QR codes ultra-résistants sur vos équipements" - -- Étiquettes waterproof et résistantes aux chocs -- Installation en quelques secondes par équipement -- Compatible avec tous types d'outils et machines - -### Étape 2: SCANNEZ - -"Utilisez votre smartphone pour scanner lors des mouvements" - -- Scan rapide (3 secondes) lors des prises/retours -- Attribution à un utilisateur, projet ou emplacement -- Fonctionne même sans connexion internet sur le chantier - -### Étape 3: CONTRÔLEZ - -"Accédez à votre tableau de bord pour tout visualiser" - -- Vue d'ensemble de votre parc matériel -- Localisation actualisée de chaque équipement -- Historique complet des mouvements et utilisations -- Alertes automatiques pour équipements non retournés - -## 4. Avantages Clés ForTooling - -### Simplicité Extrême - -"Interface conçue pour être utilisée sur chantier, même avec des gants" - -- Prise en main en moins de 5 minutes -- Pas de formation complexe nécessaire -- Utilisable par tous vos collaborateurs - -### Prix Imbattable - -"Solution jusqu'à 70% moins chère que les alternatives traditionnelles" - -- À partir de 1,90€ par jour pour une PME -- Sans achat de matériel coûteux -- ROI généralement atteint dès le premier mois - -### Adapté au Terrain - -"Conçu pour résister aux conditions difficiles des chantiers" - -- Étiquettes ultra-résistantes (poussière, eau, chocs) -- Application mobile robuste et réactive -- Mode hors-ligne pour chantiers isolés - -### Déploiement Express - -"Opérationnel en 48h, sans perturber votre activité" - -- Assistance à la mise en place incluse -- Import facile de vos inventaires existants -- Support réactif par téléphone et email - -## 5. Offre Spéciale Lancement - -### Programme Pionnier ForTooling - -"Rejoignez nos premiers utilisateurs et bénéficiez d'avantages exclusifs" - -- **50% de réduction** sur l'abonnement première année -- **Mise en place et formation offertes** (valeur 500€) -- **Support prioritaire** avec accès direct à l'équipe -- **Influence sur les futures fonctionnalités** - -_Limité aux 20 premiers clients_ - -### CTA Principal Renforcé - -"RÉSERVEZ VOTRE PLACE DANS LE PROGRAMME PIONNIER" - -### Garantie Satisfaction - -"Essai 14 jours sans engagement - Satisfait ou remboursé 30 jours" - -## 6. FAQ Stratégique - -Questions traitant directement les objections potentielles: - -- **Q: Est-ce vraiment adapté à une entreprise qui débute avec la gestion numérique?** - R: Absolument! ForTooling a été conçu pour être aussi simple que possible, même pour les entreprises sans compétences techniques particulières. - -- **Q: Les QR codes résistent-ils vraiment aux conditions de chantier?** - R: Nos étiquettes sont spécialement conçues pour l'environnement BTP - résistantes à l'eau, la poussière, les UV et les chocs modérés. - -- **Q: Comment ForTooling se compare aux grandes solutions du marché?** - R: Nous offrons les fonctionnalités essentielles des grandes solutions (suivi, attribution, historique) mais à une fraction du prix et sans la complexité inutile. - -- **Q: Que se passe-t-il si nous n'avons pas de connexion sur le chantier?** - R: L'application fonctionne parfaitement hors-ligne et synchronise automatiquement les données dès qu'une connexion est disponible. - -- **Q: Combien de temps pour être opérationnel?** - R: La plupart de nos clients sont pleinement opérationnels en 24-48h, incluant l'étiquetage de leurs premiers équipements. - -## 7. CTA Final - -### Appel à l'action de clôture - -"Rejoignez les entreprises BTP qui transforment leur gestion de matériel" - -### Formulaire Simplifié - -- Email professionnel -- Numéro de téléphone -- Taille approximative du parc d'équipements - -### Bouton d'envoi - -"DÉMARRER MON ESSAI GRATUIT" - -### Réassurance finale - -"Sans engagement - Configuration en 48h - Support inclus" - ----- -.vscode/tasks.json -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Run ESLint Fix", - "type": "shell", - "command": "bun run lint:fix", - "group": "build", - "presentation": { - "reveal": "silent", - "panel": "new" - }, - "problemMatcher": [] - } - ] -} - ----- -.husky/pre-commit -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -# Check Prettier formatting -npm run format:check || ( - echo '❌ Prettier check failed.'; - false; -) - -# Check ESLint rules -npm run lint || ( - echo '❌ ESLint check failed.'; - false; -) ----- -.husky/_/prepare-commit-msg -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/pre-rebase -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/pre-push -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/pre-merge-commit -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/pre-commit -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/pre-auto-gc -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/pre-applypatch -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/post-rewrite -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/post-merge -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/post-commit -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/post-checkout -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/post-applypatch -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/husky.sh -echo "husky - DEPRECATED - -Please remove the following two lines from $0: - -#!/usr/bin/env sh -. \"\$(dirname -- \"\$0\")/_/husky.sh\" - -They WILL FAIL in v10.0.0 -" ----- -.husky/_/h -#!/usr/bin/env sh -[ "$HUSKY" = "2" ] && set -x -n=$(basename "$0") -s=$(dirname "$(dirname "$0")")/$n - -[ ! -f "$s" ] && exit 0 - -if [ -f "$HOME/.huskyrc" ]; then - echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh" -fi -i="${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh" -[ -f "$i" ] && . "$i" - -[ "${HUSKY-}" = "0" ] && exit 0 - -export PATH="node_modules/.bin:$PATH" -sh -e "$s" "$@" -c=$? - -[ $c != 0 ] && echo "husky - $n script failed (code $c)" -[ $c = 127 ] && echo "husky - command not found in PATH=$PATH" -exit $c - ----- -.husky/_/commit-msg -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.husky/_/applypatch-msg -#!/usr/bin/env sh -. "$(dirname "$0")/h" ----- -.cursor/rules/rules-diagram-mermaid.mdc ---- -description: -globs: -alwaysApply: true ---- -erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} - -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} - -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} - -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} - -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} - -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} - -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees - -User }o--o{ Assignment : "is assigned to" - -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" - -Project }o--o{ Assignment : includes - ---END-- \ No newline at end of file diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index 45a7cd2..3ea3eba 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -10,8 +10,11 @@ import { _createOrganization, _updateOrganization, } from '@/app/actions/services/pocketbase/organization/internal' -import { User, Organization } from '@/types/types_pocketbase' -import { clerkClient } from '@clerk/nextjs/server' +import { + AppUser, + Organization as PBOrganization, +} from '@/types/types_pocketbase' +import { clerkClient, User } from '@clerk/nextjs/server' /** * Type definitions for Clerk user data @@ -71,18 +74,12 @@ type ClerkMembershipData = { /** * Synchronizes Clerk user data to PocketBase - * @param userData The user data from Clerk webhook or API + * @param user The user data from Clerk webhook or API * @returns The created or updated user */ export async function syncUserToPocketBase(user: User): Promise { try { - const { - emailAddresses, - firstName, - id: clerkId, - lastName, - ...otherUserData - } = user + const { emailAddresses, firstName, id: clerkId, lastName } = user if (!clerkId) { throw new Error('Clerk user ID is required for syncing') @@ -106,7 +103,7 @@ export async function syncUserToPocketBase(user: User): Promise { name: firstName && lastName ? `${firstName} ${lastName}` - : user.username || 'Unknown', + : (user.username ?? 'Unknown'), verified: primaryEmail.verification?.status === 'verified', // Add other fields as needed } @@ -127,23 +124,29 @@ export async function syncUserToPocketBase(user: User): Promise { /** * Synchronizes Clerk organization data to PocketBase - * @param orgData The organization data from Clerk webhook or API + * @param organization The organization data from Clerk webhook or API * @returns The created or updated organization */ export async function syncOrganizationToPocketBase( - organization: ClerkOrganization -): Promise { + organization: ClerkOrganizationData +): Promise { try { - const { id: clerkId, name, ...otherOrgData } = organization + const { id: clerkId, name } = organization if (!clerkId) { throw new Error('Clerk organization ID is required for syncing') } + console.info(`Attempting to sync organization with clerkId: ${clerkId}`) + + // Try to find existing Organization + const existingOrg = await getOrganizationByClerkId(clerkId) + console.info('Existing org lookup result:', existingOrg) + // Prepare organization data const orgDataToSync = { clerkId, - name: name || 'Unnamed Organization', + name: name ?? 'Unnamed Organization', // Add other fields as needed } @@ -185,12 +188,12 @@ export async function linkUserToOrganization( // Find the user in PocketBase by Clerk ID const pbUser = await pb - .collection('users') + .collection('AppUser') .getFirstListItem(`clerkId=${userId}`) // Find the organization in PocketBase by Clerk ID const pbOrg = await pb - .collection('organizations') + .collection('Organization') .getFirstListItem(`clerkId=${orgId}`) // Check if the relation already exists @@ -204,7 +207,7 @@ export async function linkUserToOrganization( if (existingRelations.totalItems === 0) { await pb.collection('user_organizations').create({ organization: pbOrg.id, - role: role || 'member', + role: role ?? 'member', user: pbUser.id, }) } else { @@ -212,13 +215,13 @@ export async function linkUserToOrganization( await pb .collection('user_organizations') .update(existingRelations.items[0].id, { - role: role || 'member', + role: role ?? 'member', }) } // Update user if they're an admin in the organization if (role === 'admin') { - await pb.collection('users').update(pbUser.id, { + await pb.collection('AppUser').update(pbUser.id, { isAdmin: true, role: 'admin', }) @@ -243,15 +246,8 @@ export async function getClerkUserById( const clerkClientInstance = await clerkClient() const user = await clerkClientInstance.users.getUser(clerkId) return { - // email_addresses: user.emailAddresses.map(email => ({ - // email_address: email.emailAddress, - // verification: email.verification, - // })), - // first_name: user.firstName, id: user.id, image_url: user.imageUrl, - // last_name: user.lastName, - // username: user.username, } } catch (error) { console.error('Error fetching user from Clerk:', error) @@ -274,10 +270,8 @@ export async function getClerkOrganizationById( organizationId: clerkId, }) return { - // email_address: organization.email_address, id: organization.id, name: organization.name, - // phone_number: organization.phone_number, } } catch (error) { console.error('Error fetching organization from Clerk:', error) From 2fd8b1b06c97424db5d015fe5b5acccaabd7a811 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:22:38 +0200 Subject: [PATCH 34/73] feat(docs): add example export for PB schema - Create a new markdown file with JSON schema examples - Include detailed structure for various entities like AppUser, Assignment, and Organization - Define fields and their properties for each entity --- .cursor/md/example-export-pb-schema.md | 965 +++++++++++++++++++++++++ 1 file changed, 965 insertions(+) create mode 100644 .cursor/md/example-export-pb-schema.md diff --git a/.cursor/md/example-export-pb-schema.md b/.cursor/md/example-export-pb-schema.md new file mode 100644 index 0000000..958eec9 --- /dev/null +++ b/.cursor/md/example-export-pb-schema.md @@ -0,0 +1,965 @@ +# pb schema +```json +[ + { + "id": "pbc_879879449", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "AppUser", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3885137012", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3500197394", + "hidden": false, + "id": "relation376926767", + "maxSelect": 1, + "minSelect": 0, + "name": "avatar", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1466534506", + "max": 0, + "min": 0, + "name": "role", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool2165931080", + "name": "isAdmin", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "date2697416787", + "max": "", + "min": "", + "name": "lastLogin", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3875972033", + "max": 0, + "min": 0, + "name": "clerkId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation1115430015", + "maxSelect": 1, + "minSelect": 0, + "name": "organizations", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2166913018", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Assignment", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation1706602226", + "maxSelect": 1, + "minSelect": 0, + "name": "assignedToUser", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1901958808", + "hidden": false, + "id": "relation3498911044", + "maxSelect": 1, + "minSelect": 0, + "name": "assignedToProject", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "date1269603864", + "max": "", + "min": "", + "name": "startDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date826688707", + "max": "", + "min": "", + "name": "endDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_647898912", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "ActivityLog", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_156890547", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Equipement", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text231537297", + "max": 0, + "min": 0, + "name": "qrNfcCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1874629670", + "max": 0, + "min": 0, + "name": "tags", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "date58351749", + "max": "", + "min": "", + "name": "acquisitionDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation1849591526", + "maxSelect": 1, + "minSelect": 0, + "name": "parentEquipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3500197394", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Images", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text678750603", + "max": 0, + "min": 0, + "name": "alt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4135340389", + "max": 0, + "min": 0, + "name": "caption", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "file3309110367", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "image", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2387082370", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Organization", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3885137012", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text223244161", + "max": 0, + "min": 0, + "name": "address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json3846545605", + "maxSize": 0, + "name": "settings", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3875972033", + "max": 0, + "min": 0, + "name": "clerkId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1278432162", + "max": 0, + "min": 0, + "name": "stripeCustomerId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3396850601", + "max": 0, + "min": 0, + "name": "subscriptionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text212635077", + "max": 0, + "min": 0, + "name": "subscriptionStatus", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2938615432", + "max": 0, + "min": 0, + "name": "priceId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1901958808", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Project", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text223244161", + "max": 0, + "min": 0, + "name": "address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "date1269603864", + "max": "", + "min": "", + "name": "startDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date826688707", + "max": "", + "min": "", + "name": "endDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + } +] +``` \ No newline at end of file From e4b2a78c30826a86224190c278932cb5e810ff9f Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:24:19 +0200 Subject: [PATCH 35/73] feat(api): enhance user and organization sync logic - Add default values for email visibility and admin status in user data - Update organization data structure to include empty settings object - Simplify linking users to organizations by directly updating the user record - Change console logs to info level for better logging clarity - Modify types to make certain fields optional in the Organization interface --- .../services/clerk-sync/syncService.ts | 41 ++++++------------- .../pocketbase/organization/internal.ts | 14 +++---- src/types/types_pocketbase.ts | 31 ++++++-------- 3 files changed, 33 insertions(+), 53 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index 3ea3eba..3a5107a 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -97,15 +97,17 @@ export async function syncUserToPocketBase(user: User): Promise { const existingUser = await getByClerkId(clerkId) // Prepare user data - const userDataToSync = { + const userDataToSync: Partial = { clerkId, email: primaryEmail.emailAddress, + emailVisibility: true, // Set default value + isAdmin: false, // Set default value name: firstName && lastName ? `${firstName} ${lastName}` : (user.username ?? 'Unknown'), verified: primaryEmail.verification?.status === 'verified', - // Add other fields as needed + // Leave organizations as empty for now } // Update or create @@ -144,10 +146,11 @@ export async function syncOrganizationToPocketBase( console.info('Existing org lookup result:', existingOrg) // Prepare organization data - const orgDataToSync = { + const orgDataToSync: Partial = { clerkId, name: name ?? 'Unnamed Organization', - // Add other fields as needed + // Add default values for required fields based on schema + settings: {}, // Empty JSON object for settings } // Update or create @@ -189,35 +192,17 @@ export async function linkUserToOrganization( // Find the user in PocketBase by Clerk ID const pbUser = await pb .collection('AppUser') - .getFirstListItem(`clerkId=${userId}`) + .getFirstListItem(`clerkId="${userId}"`) // Find the organization in PocketBase by Clerk ID const pbOrg = await pb .collection('Organization') - .getFirstListItem(`clerkId=${orgId}`) + .getFirstListItem(`clerkId=`${orgId}``) - // Check if the relation already exists - const existingRelations = await pb - .collection('user_organizations') - .getList(1, 1, { - filter: `user="${pbUser.id}" && organization="${pbOrg.id}"`, - }) - - // If the relation doesn't exist, create it - if (existingRelations.totalItems === 0) { - await pb.collection('user_organizations').create({ - organization: pbOrg.id, - role: role ?? 'member', - user: pbUser.id, - }) - } else { - // Update the existing relation if needed - await pb - .collection('user_organizations') - .update(existingRelations.items[0].id, { - role: role ?? 'member', - }) - } + // Update the user with the organization relation + await pb.collection('AppUser').update(pbUser.id, { + organizations: pbOrg.id + }); // Update user if they're an admin in the organization if (role === 'admin') { diff --git a/src/app/actions/services/pocketbase/organization/internal.ts b/src/app/actions/services/pocketbase/organization/internal.ts index 6bd2c60..7dcb044 100644 --- a/src/app/actions/services/pocketbase/organization/internal.ts +++ b/src/app/actions/services/pocketbase/organization/internal.ts @@ -25,7 +25,7 @@ export async function _createOrganization( throw new Error('Failed to connect to PocketBase') } - console.log('Creating new Organization with data:', data) + console.info('Creating new Organization with data:', data) // Make sure clerkId is included if (!data.clerkId) { @@ -33,7 +33,7 @@ export async function _createOrganization( } const newOrg = await pb.collection('Organization').create(data) - console.log('New Organization created:', newOrg) + console.info('New Organization created:', newOrg) return newOrg } catch (error) { @@ -57,10 +57,10 @@ export async function _updateOrganization( throw new Error('Failed to connect to PocketBase') } - console.log(`Updating Organization ${id} with data:`, data) + console.info(`Updating Organization ${id} with data:`, data) const updatedOrg = await pb.collection('Organization').update(id, data) - console.log('Organization updated:', updatedOrg) + console.info('Organization updated:', updatedOrg) return updatedOrg } catch (error) { @@ -102,14 +102,14 @@ export async function getOrganizationByClerkId( throw new Error('Failed to connect to PocketBase') } - console.log(`Searching for Organization with clerkId: ${clerkId}`) + console.info(`Searching for Organization with clerkId: ${clerkId}`) try { const org = await pb .collection('Organization') .getFirstListItem(`clerkId="${clerkId}"`) - console.log('Organization found:', org) + console.info('Organization found:', org) return org } catch (error) { // Check if this is a "not found" error @@ -117,7 +117,7 @@ export async function getOrganizationByClerkId( error instanceof Error && (error.message.includes('404') || error.message.includes('not found')) ) { - console.log(`No organization found with clerkId: ${clerkId}`) + console.info(`No organization found with clerkId: ${clerkId}`) return null } // Otherwise rethrow the error diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index f2b312c..9f29744 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -14,10 +14,10 @@ export interface BaseModel { */ export interface Organization extends BaseModel { name: string - email: string | null - phone: string | null - address: string | null - settings: Record | null + email?: string + phone?: string + address?: string + settings?: Record // Clerk integration fields clerkId: string @@ -27,9 +27,6 @@ export interface Organization extends BaseModel { subscriptionId?: string subscriptionStatus?: string priceId?: string - - // Expanded relations - expand?: Record } /** @@ -37,21 +34,19 @@ export interface Organization extends BaseModel { */ export interface AppUser { id: string - name: string email: string + emailVisibility: boolean + verified: boolean + name: string + avatar?: string phone?: string role?: string - isAdmin?: boolean - verified?: boolean - emailVisibility?: boolean - clerkId?: string + isAdmin: boolean lastLogin?: string - created?: string - updated?: string - expand?: { - organizations?: Organization[] - } - organizations?: string[] // Organization IDs + clerkId: string + organizations?: string + created: string + updated: string } /** From 7a099a8cbb02f08ffbb5ad214aa31ab77b48a1bf Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:26:29 +0200 Subject: [PATCH 36/73] fix(core): correct string interpolation in sync service - Fix template literal syntax for Clerk ID retrieval - Ensure proper object formatting in user update call - Improve code readability and maintainability --- src/app/actions/services/clerk-sync/syncService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index 3a5107a..ab98711 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -197,12 +197,12 @@ export async function linkUserToOrganization( // Find the organization in PocketBase by Clerk ID const pbOrg = await pb .collection('Organization') - .getFirstListItem(`clerkId=`${orgId}``) + .getFirstListItem(`clerkId=${orgId}`) // Update the user with the organization relation await pb.collection('AppUser').update(pbUser.id, { - organizations: pbOrg.id - }); + organizations: pbOrg.id, + }) // Update user if they're an admin in the organization if (role === 'admin') { From 6140d96a8b96e538ab446b2f81b6c9b0b7bc8e0c Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 19:33:16 +0200 Subject: [PATCH 37/73] feat(api): enhance user-organization linking process - Add logging for user and organization linking actions - Improve error handling for PocketBase connections - Ensure proper formatting of Clerk IDs in queries - Update user roles with clear console feedback --- src/app/actions/services/clerk-sync/syncService.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index ab98711..ccef85d 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -189,20 +189,29 @@ export async function linkUserToOrganization( throw new Error('Failed to connect to PocketBase') } + console.info( + `Linking user ${userId} to organization ${orgId} with role ${role ?? 'member'}` + ) + // Find the user in PocketBase by Clerk ID const pbUser = await pb .collection('AppUser') - .getFirstListItem(`clerkId="${userId}"`) + .getFirstListItem(`clerkId="` + userId + `"`) // Find the organization in PocketBase by Clerk ID const pbOrg = await pb .collection('Organization') - .getFirstListItem(`clerkId=${orgId}`) + .getFirstListItem(`clerkId="` + orgId + `"`) + + console.info( + `Found user ${pbUser.id} and organization ${pbOrg.id} in PocketBase` + ) // Update the user with the organization relation await pb.collection('AppUser').update(pbUser.id, { organizations: pbOrg.id, }) + console.info(`Updated user ${pbUser.id} with organization ${pbOrg.id}`) // Update user if they're an admin in the organization if (role === 'admin') { @@ -210,6 +219,7 @@ export async function linkUserToOrganization( isAdmin: true, role: 'admin', }) + console.info(`Updated user ${pbUser.id} to admin role`) } return true From 68625f0cadecdabcb9580752f46d9489e61cbcd6 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 22:15:26 +0200 Subject: [PATCH 38/73] feat(core): enhance user and organization sync logic - Expand user data synchronization to include additional fields like phone numbers and last sign-in date - Improve organization data handling with more metadata attributes - Normalize user roles during linking to organizations - Add new function for syncing user profile images from Clerk to PocketBase - Implement error handling for image fetching and uploading processes --- .../services/clerk-sync/syncService.ts | 127 +++++++++++++++--- 1 file changed, 112 insertions(+), 15 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index ccef85d..bc5ece7 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -79,8 +79,22 @@ type ClerkMembershipData = { */ export async function syncUserToPocketBase(user: User): Promise { try { - const { emailAddresses, firstName, id: clerkId, lastName } = user + const { + createdAt, + emailAddresses, + firstName, + id: clerkId, + imageUrl, + lastName, + lastSignInAt, + phoneNumbers, + primaryPhoneNumberId, + publicMetadata, + updatedAt, + } = user + console.log('clerkId', clerkId) + console.log('user', user) if (!clerkId) { throw new Error('Clerk user ID is required for syncing') } @@ -93,21 +107,31 @@ export async function syncUserToPocketBase(user: User): Promise { throw new Error('User must have a primary email address') } + // Get primary phone if available + const primaryPhone = phoneNumbers.find( + phone => phone.id === primaryPhoneNumberId + ) + // Try to find existing AppUser const existingUser = await getByClerkId(clerkId) - // Prepare user data + // Prepare user data with all available fields const userDataToSync: Partial = { clerkId, email: primaryEmail.emailAddress, - emailVisibility: true, // Set default value - isAdmin: false, // Set default value + emailVisibility: true, + // Set admin status from metadata if available + isAdmin: publicMetadata?.isAdmin === true, + // Convert clerk's lastSignInAt to a format PocketBase understands + lastLogin: lastSignInAt ? new Date(lastSignInAt).toISOString() : '', name: firstName && lastName ? `${firstName} ${lastName}` : (user.username ?? 'Unknown'), + phone: primaryPhone?.phoneNumber || '', + // Set role from metadata if available + role: typeof publicMetadata?.role === 'string' ? publicMetadata.role : '', verified: primaryEmail.verification?.status === 'verified', - // Leave organizations as empty for now } // Update or create @@ -133,7 +157,15 @@ export async function syncOrganizationToPocketBase( organization: ClerkOrganizationData ): Promise { try { - const { id: clerkId, name } = organization + const { + created_at, + email_address, + id: clerkId, + name, + phone_number, + public_metadata, + updated_at, + } = organization if (!clerkId) { throw new Error('Clerk organization ID is required for syncing') @@ -145,12 +177,20 @@ export async function syncOrganizationToPocketBase( const existingOrg = await getOrganizationByClerkId(clerkId) console.info('Existing org lookup result:', existingOrg) - // Prepare organization data + // Prepare organization data with all available fields const orgDataToSync: Partial = { + address: public_metadata?.address ?? '', clerkId, + email: email_address ?? '', name: name ?? 'Unnamed Organization', - // Add default values for required fields based on schema - settings: {}, // Empty JSON object for settings + phone: phone_number ?? '', + priceId: public_metadata?.priceId ?? '', + // Sync settings from metadata if available + settings: public_metadata?.settings ?? {}, + // Sync stripe data if available in metadata + stripeCustomerId: public_metadata?.stripeCustomerId ?? '', + subscriptionId: public_metadata?.subscriptionId ?? '', + subscriptionStatus: public_metadata?.subscriptionStatus ?? '', } // Update or create @@ -178,7 +218,10 @@ export async function linkUserToOrganization( try { const userId = membershipData.public_user_data?.user_id const orgId = membershipData.organization.id - const role = membershipData.role // e.g., 'admin', 'member' + const role = membershipData.role // e.g., 'org:admin', 'org:member' + + // Extract just the role part (remove 'org:' prefix if present) + const normalizedRole = role?.replace('org:', '') ?? 'member' if (!userId || !orgId) { throw new Error('Missing required IDs in membership data') @@ -190,18 +233,18 @@ export async function linkUserToOrganization( } console.info( - `Linking user ${userId} to organization ${orgId} with role ${role ?? 'member'}` + `Linking user ${userId} to organization ${orgId} with role ${normalizedRole}` ) // Find the user in PocketBase by Clerk ID const pbUser = await pb .collection('AppUser') - .getFirstListItem(`clerkId="` + userId + `"`) + .getFirstListItem(`clerkId="${userId}"`) // Find the organization in PocketBase by Clerk ID const pbOrg = await pb .collection('Organization') - .getFirstListItem(`clerkId="` + orgId + `"`) + .getFirstListItem(`clerkId="${orgId}"`) console.info( `Found user ${pbUser.id} and organization ${pbOrg.id} in PocketBase` @@ -213,13 +256,19 @@ export async function linkUserToOrganization( }) console.info(`Updated user ${pbUser.id} with organization ${pbOrg.id}`) - // Update user if they're an admin in the organization - if (role === 'admin') { + // Update user role based on organization membership + if (normalizedRole === 'admin') { await pb.collection('AppUser').update(pbUser.id, { isAdmin: true, role: 'admin', }) console.info(`Updated user ${pbUser.id} to admin role`) + } else { + // Update the role even if not admin + await pb.collection('AppUser').update(pbUser.id, { + role: normalizedRole, + }) + console.info(`Updated user ${pbUser.id} to ${normalizedRole} role`) } return true @@ -273,3 +322,51 @@ export async function getClerkOrganizationById( throw error } } + +/** + * Synchronizes user profile image from Clerk to PocketBase + * @param userId The PocketBase user ID + * @param imageUrl The Clerk image URL + * @returns Success status + */ +export async function syncUserProfileImage( + userId: string, + imageUrl: string +): Promise { + if (!imageUrl) return false + + try { + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fetch the image data + const response = await fetch(imageUrl) + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.statusText}`) + } + + // Convert to blob + const imageBlob = await response.blob() + + // Create a file object from the blob + const formData = new FormData() + formData.append('image', imageBlob, 'profile.jpg') + formData.append('title', 'Profile Photo') + formData.append('alt', 'User profile photo') + + // Create the image record in PocketBase + const imageRecord = await pb.collection('Images').create(formData) + + // Update the user with the image relation + await pb.collection('AppUser').update(userId, { + avatar: imageRecord.id, + }) + + return true + } catch (error) { + console.error('Error syncing user profile image:', error) + return false + } +} From 47f84bf29238624491f7a2a6355942b5a554ab7c Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sun, 30 Mar 2025 22:25:08 +0200 Subject: [PATCH 39/73] feat(api): enhance user sync with metadata support - Add json metadata field to user model - Improve logging for user synchronization process - Update admin status and onboarding completion checks from metadata - Correctly type metadata fields in sync functions - Fix type issues for organization settings and subscription details --- .cursor/rules/rules-diagram-mermaid.mdc | 1 + .../services/clerk-sync/syncService.ts | 50 +++++++++++++------ src/types/types_pocketbase.ts | 2 + 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/.cursor/rules/rules-diagram-mermaid.mdc b/.cursor/rules/rules-diagram-mermaid.mdc index bb11bf7..088a424 100644 --- a/.cursor/rules/rules-diagram-mermaid.mdc +++ b/.cursor/rules/rules-diagram-mermaid.mdc @@ -33,6 +33,7 @@ erDiagram date lastLogin date created date updated + json metadata } Equipment { diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index bc5ece7..df7e7f6 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -84,7 +84,6 @@ export async function syncUserToPocketBase(user: User): Promise { emailAddresses, firstName, id: clerkId, - imageUrl, lastName, lastSignInAt, phoneNumbers, @@ -93,8 +92,9 @@ export async function syncUserToPocketBase(user: User): Promise { updatedAt, } = user - console.log('clerkId', clerkId) - console.log('user', user) + console.info('Syncing user with clerkId:', clerkId) + console.info('User data:', JSON.stringify(user, null, 2)) + if (!clerkId) { throw new Error('Clerk user ID is required for syncing') } @@ -120,16 +120,31 @@ export async function syncUserToPocketBase(user: User): Promise { clerkId, email: primaryEmail.emailAddress, emailVisibility: true, - // Set admin status from metadata if available + // Set admin status based on metadata isAdmin: publicMetadata?.isAdmin === true, - // Convert clerk's lastSignInAt to a format PocketBase understands + // Convert Clerk's lastSignInAt to a format PocketBase understands lastLogin: lastSignInAt ? new Date(lastSignInAt).toISOString() : '', + // Now correctly typed thanks to the interface update + metadata: { + createdAt: createdAt, + externalAccounts: + user.externalAccounts?.map(account => ({ + email: account.emailAddress, + imageUrl: account.imageUrl, + provider: account.provider, + providerUserId: account.externalId, + })) ?? [], // Use ?? instead of || for better null handling + hasCompletedOnboarding: publicMetadata?.hasCompletedOnboarding === true, + lastActiveAt: user.lastActiveAt, + onboardingCompletedAt: publicMetadata?.onboardingCompletedAt, + public: publicMetadata ?? {}, // Use ?? instead of || + updatedAt: updatedAt, + }, name: firstName && lastName ? `${firstName} ${lastName}` : (user.username ?? 'Unknown'), - phone: primaryPhone?.phoneNumber || '', - // Set role from metadata if available + phone: primaryPhone?.phoneNumber ?? '', // Use ?? instead of || role: typeof publicMetadata?.role === 'string' ? publicMetadata.role : '', verified: primaryEmail.verification?.status === 'verified', } @@ -184,13 +199,20 @@ export async function syncOrganizationToPocketBase( email: email_address ?? '', name: name ?? 'Unnamed Organization', phone: phone_number ?? '', - priceId: public_metadata?.priceId ?? '', - // Sync settings from metadata if available - settings: public_metadata?.settings ?? {}, - // Sync stripe data if available in metadata - stripeCustomerId: public_metadata?.stripeCustomerId ?? '', - subscriptionId: public_metadata?.subscriptionId ?? '', - subscriptionStatus: public_metadata?.subscriptionStatus ?? '', + // Fix type issues - convert to string instead of empty object + priceId: (public_metadata?.priceId as string) ?? '', // Type correction + settings: { + ...(public_metadata?.settings ?? {}), + clerkData: { + createdAt: created_at, + updatedAt: updated_at, + ...(public_metadata ?? {}), + }, + }, + // Fix type issues - convert to string instead of empty object + stripeCustomerId: (public_metadata?.stripeCustomerId as string) ?? '', // Type correction + subscriptionId: (public_metadata?.subscriptionId as string) ?? '', // Type correction + subscriptionStatus: (public_metadata?.subscriptionStatus as string) ?? '', // Type correction } // Update or create diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index 9f29744..9fc254d 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -47,6 +47,8 @@ export interface AppUser { organizations?: string created: string updated: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record } /** From 2439eefb5bf061d18e63d5da73caa15a401a55bf Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 11:02:08 +0200 Subject: [PATCH 40/73] feat(api): enhance webhook handling and logging - Update logging from console.log to console.info for better clarity - Refactor error handling in user deletion to throw errors instead of returning false - Improve security utility imports for consistency - Adjust user data types in various functions to use AppUser interface - Streamline webhook processing logic with clearer event type handling --- bun.lock | 2 +- .../services/pocketbase/app-user/auth.ts | 2 +- .../services/pocketbase/app-user/internal.ts | 4 +- .../pocketbase/app-user/webhook-handlers.ts | 38 ++- .../services/pocketbase/securityUtils.ts | 6 +- .../actions/services/pocketbase/user/core.ts | 53 ++--- src/app/api/webhook/clerk/user/route.ts | 219 +++++++----------- src/lib/webhookUtils.ts | 5 + src/types/types_pocketbase.ts | 2 +- 9 files changed, 134 insertions(+), 197 deletions(-) diff --git a/bun.lock b/bun.lock index fee0e92..5125a21 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", - "crypto": "^1.0.1", + "crypto": "1.0.1", "dayjs": "1.11.13", "framer-motion": "12.6.2", "heroicons": "2.2.0", diff --git a/src/app/actions/services/pocketbase/app-user/auth.ts b/src/app/actions/services/pocketbase/app-user/auth.ts index ac7c59e..f4e0d3c 100644 --- a/src/app/actions/services/pocketbase/app-user/auth.ts +++ b/src/app/actions/services/pocketbase/app-user/auth.ts @@ -5,7 +5,7 @@ import { getPocketBase, handlePocketBaseError, } from '@/app/actions/services/pocketbase/baseService' -import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' +import { SecurityError } from '@/app/actions/services/securyUtilsTools' import { AppUser } from '@/types/types_pocketbase' /** diff --git a/src/app/actions/services/pocketbase/app-user/internal.ts b/src/app/actions/services/pocketbase/app-user/internal.ts index 9190458..fb5f113 100644 --- a/src/app/actions/services/pocketbase/app-user/internal.ts +++ b/src/app/actions/services/pocketbase/app-user/internal.ts @@ -75,8 +75,8 @@ export async function _deleteAppUser(id: string): Promise { await pb.collection('AppUser').delete(id) return true } catch (error) { - handlePocketBaseError(error, 'AppUserService._deleteAppUser') - return false + console.error('Error deleting app user:', error) + throw error } } diff --git a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts index fc0b4e5..5bfa0b9 100644 --- a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts +++ b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts @@ -73,13 +73,13 @@ export async function handleWebhookUpdated( elevated = true ): Promise { try { - console.log('Processing user update webhook for clerkId:', data.id) + console.info('Processing user update webhook for clerkId:', data.id) // Find existing user const existing = await getByClerkId(data.id) if (!existing) { - console.log( + console.info( `AppUser with clerkId ${data.id} not found, creating new user` ) // If user doesn't exist, create it instead of updating @@ -87,7 +87,7 @@ export async function handleWebhookUpdated( } // Continue with the update logic... - console.log(`Updating existing AppUser: ${existing.id}`) + console.info(`Updating existing AppUser: ${existing.id}`) // Get primary email if available let email = existing.email // Default to existing @@ -121,30 +121,46 @@ export async function handleWebhookUpdated( } /** - * Handles a webhook event for user deletion - * @param {ClerkUserWebhookData} data - User deletion data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result + * Handles the user.deleted webhook event from Clerk + * @param data The webhook data from Clerk + * @param elevated Whether to use elevated permissions + * @returns Result of the operation */ export async function handleWebhookDeleted( data: ClerkUserWebhookData, elevated = true ): Promise { try { + const clerkId = data.id + console.info(`Processing user deletion webhook for clerkId: ${clerkId}`) + // Find existing user - const existing = await getByClerkId(data.id) + const existing = await getByClerkId(clerkId) + + // If user doesn't exist in PocketBase, just log and return success if (!existing) { + console.info( + `AppUser with clerkId ${clerkId} not found in PocketBase. Nothing to delete.` + ) return { - message: `AppUser ${data.id} already deleted or not found`, + message: `No user found with clerkId ${clerkId}. No action needed.`, success: true, } } - // Delete user + console.info( + `Found AppUser with id ${existing.id} and clerkId ${clerkId}. Deleting...` + ) + + // Delete the user from PocketBase await deleteAppUser(existing.id, elevated) + console.info( + `Successfully deleted AppUser with id ${existing.id} and clerkId ${clerkId}` + ) + return { - message: `Deleted AppUser ${data.id}`, + message: `Successfully deleted user with clerkId ${clerkId}`, success: true, } } catch (error) { diff --git a/src/app/actions/services/pocketbase/securityUtils.ts b/src/app/actions/services/pocketbase/securityUtils.ts index e47f593..afaefdc 100644 --- a/src/app/actions/services/pocketbase/securityUtils.ts +++ b/src/app/actions/services/pocketbase/securityUtils.ts @@ -30,8 +30,8 @@ export async function validateCurrentUser(userId?: string): Promise { try { // Find the user by Clerk ID const user = await pb - .collection('users') - .getFirstListItem(`clerkId=${clerkUserId}`) + .collection('AppUser') + .getFirstListItem(`clerkId=${'"' + clerkUserId + '"'}`) // If a specific user ID was provided, verify it matches the current user if (userId && user.id !== userId) { @@ -60,7 +60,7 @@ export async function validateOrganizationAccess( const user = await validateCurrentUser() // Check organization membership - if (user.expand?.organizationId !== organizationId) { + if (user.organizations?.some(org => org.id === organizationId)) { throw new SecurityError('Unauthorized access to organization data') } diff --git a/src/app/actions/services/pocketbase/user/core.ts b/src/app/actions/services/pocketbase/user/core.ts index c920503..9447043 100644 --- a/src/app/actions/services/pocketbase/user/core.ts +++ b/src/app/actions/services/pocketbase/user/core.ts @@ -8,16 +8,18 @@ import { validateCurrentUser, validateOrganizationAccess, validateResourceAccess, - SecurityError, - ResourceType, - PermissionLevel, } from '@/app/actions/services/pocketbase/securityUtils' import { _updateUser, _createUser, _deleteUser, } from '@/app/actions/services/pocketbase/user/internal' -import { User } from '@/types/types_pocketbase' +import { + PermissionLevel, + ResourceType, + SecurityError, +} from '@/app/actions/services/securyUtilsTools' +import { AppUser } from '@/types/types_pocketbase' /** * Core user operations with security validations @@ -26,7 +28,7 @@ import { User } from '@/types/types_pocketbase' /** * Get a single user by ID with security validation */ -export async function getUser(id: string): Promise { +export async function getUser(id: string): Promise { try { // Security check - validates user has access to this resource await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) @@ -48,7 +50,7 @@ export async function getUser(id: string): Promise { /** * Get current authenticated user profile */ -export async function getCurrentUser(): Promise { +export async function getCurrentUser(): Promise { try { // This function automatically validates the current user return await validateCurrentUser() @@ -63,7 +65,7 @@ export async function getCurrentUser(): Promise { /** * Get a user by Clerk ID - typically used during authentication */ -export async function getUserByClerkId(clerkId: string): Promise { +export async function getUserByClerkId(clerkId: string): Promise { // This is primarily used during authentication flows where // standard security checks aren't possible yet. // However, requests should still come from server-side code only. @@ -85,21 +87,9 @@ export async function getUserByClerkId(clerkId: string): Promise { */ export async function createUser( organizationId: string, - data: Pick< - Partial, - | 'name' - | 'email' - | 'emailVisibility' - | 'verified' - | 'avatar' - | 'phone' - | 'role' - | 'isAdmin' - | 'canLogin' - | 'clerkId' - >, + data: AppUser, elevated = false -): Promise { +): Promise { try { if (!elevated) { // Security check - requires ADMIN permission to create users @@ -124,22 +114,9 @@ export async function createUser( */ export async function updateUser( id: string, - data: Pick< - Partial, - | 'name' - | 'email' - | 'emailVisibility' - | 'verified' - | 'avatar' - | 'phone' - | 'role' - | 'isAdmin' - | 'canLogin' - | 'lastLogin' - | 'clerkId' - >, + data: AppUser, elevated = false -): Promise { +): Promise { try { if (!elevated) { // Get current authenticated user @@ -159,7 +136,7 @@ export async function updateUser( if (data.role || data.isAdmin !== undefined) { // If trying to change role or admin status, require admin permission // Get the user's organization ID - handling possible multiple organizations - const userOrgs = currentUser.expand?.organizations + const userOrgs = currentUser.organizations if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { throw new SecurityError('User does not belong to any organization') @@ -179,7 +156,7 @@ export async function updateUser( if (!elevated) { // Only elevated calls (webhooks) can change the clerkId - delete sanitizedData.clerkId + delete (sanitizedData as Record).clerkId } return await _updateUser(id, sanitizedData) diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index 4921a21..837e73f 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -1,161 +1,100 @@ -import * as appUserService from '@/app/actions/services/pocketbase/app-user' +import { + handleWebhookCreated, + handleWebhookDeleted, + handleWebhookUpdated, +} from '@/app/actions/services/pocketbase/app-user/webhook-handlers' import { verifyClerkWebhook } from '@/lib/webhookUtils' import { NextRequest, NextResponse } from 'next/server' -/** - * Handles webhook events from Clerk related to users - */ -export async function POST(req: NextRequest) { - console.log('Received Clerk webhook request') - - const webhookSecret = process.env.CLERK_WEBHOOK_SECRET_USER - - // Verify the webhook - const { payload, success } = await verifyClerkWebhook( - req.clone(), - webhookSecret - ) - - if (!success || !payload) { - console.error('Invalid webhook signature for user event') - return new NextResponse('Unauthorized', { status: 401 }) - } - - try { - // Process based on event type - const { data, type } = payload - - console.log('Webhook verified successfully:', { type }) - - if (type.startsWith('user.')) { - if (type === 'user.created') { - const result = await appUserService.handleWebhookCreated(data, true) - return NextResponse.json(result) - } else if (type === 'user.updated') { - const result = await appUserService.handleWebhookUpdated(data, true) - return NextResponse.json(result) - } else if (type === 'user.deleted') { - const result = await appUserService.handleWebhookDeleted(data, true) - return NextResponse.json(result) - } - } - return NextResponse.json({ - message: `Unhandled event type: ${type}`, - success: false, - }) - } catch (error) { - console.error('Error processing webhook:', error) - return NextResponse.json( - { message: 'Error processing webhook', success: false }, - { status: 500 } - ) - } +// Define types for Clerk webhook payload +interface ClerkWebhookData { + id: string + [key: string]: unknown } -/** - * Handles user creation event - */ -async function handleUserCreated(data: any) { - const { - email_addresses, - first_name, - id: clerkId, - last_name, - profile_image_url, - public_metadata, - } = data - - // Find primary email - const primaryEmail = email_addresses.find( - (email: any) => email.id === data.primary_email_address_id - )?.email_address - - console.log(`User created: ${clerkId} (${primaryEmail})`) - - try { - await userService.createUser({ - clerkId, - email: primaryEmail, - firstName: first_name || '', - lastName: last_name || '', - profileImageUrl: profile_image_url, - publicMetadata: public_metadata || {}, - }) - - console.log(`Successfully created user in PocketBase: ${clerkId}`) - } catch (error) { - console.error(`Failed to create user in PocketBase: ${clerkId}`, error) - throw error - } +interface ClerkWebhookEvent { + type: string + data: ClerkWebhookData } /** - * Handles user update event + * Handles Clerk webhook requests for user-related events */ -async function handleUserUpdated(data: any) { - const { - email_addresses, - first_name, - id: clerkId, - last_name, - profile_image_url, - public_metadata, - } = data - - // Find primary email - const primaryEmail = email_addresses.find( - (email: any) => email.id === data.primary_email_address_id - )?.email_address - - console.log(`User updated: ${clerkId} (${primaryEmail})`) +export async function POST(req: NextRequest) { + console.info('Received Clerk webhook request') try { - const user = await userService.findByClerkId(clerkId) - - if (!user) { - console.error(`User not found in PocketBase for clerkId: ${clerkId}`) - return + // Parse the request body + const body = (await req.json()) as ClerkWebhookEvent + + // Get the Svix headers for verification + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') + + // Validate that we have all required headers + if (!svixId || !svixTimestamp || !svixSignature) { + console.error('Missing required Svix headers') + return new NextResponse('Unauthorized: Missing verification headers', { + status: 401, + }) } - await userService.updateUser(user.id, { - email: primaryEmail, - firstName: first_name || '', - lastName: last_name || '', - profileImageUrl: profile_image_url, - publicMetadata: public_metadata || {}, - }) - - console.log(`Successfully updated user in PocketBase: ${clerkId}`) - } catch (error) { - console.error(`Failed to update user in PocketBase: ${clerkId}`, error) - throw error - } -} - -/** - * Handles user deletion event - */ -async function handleUserDeleted(data: any) { - const { id: clerkId } = data - - console.log(`User deleted: ${clerkId}`) - - try { - const user = await userService.findByClerkId(clerkId) + // Verify the webhook signature + const isValid = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_USER + ) - if (!user) { - console.log(`User not found in PocketBase for clerkId: ${clerkId}`) - return + if (!isValid) { + console.error('Invalid webhook signature for user event') + return new NextResponse('Unauthorized: Invalid signature', { + status: 401, + }) } - await userService.softDeleteUser(user.id) + console.info('Webhook verified successfully:', { type: body.type }) + + // Process the webhook based on its type + let result + + switch (body.type) { + case 'user.created': + console.info('Processing user.created event') + result = await handleWebhookCreated(body.data) + break + + case 'user.updated': + console.info('Processing user.updated event') + result = await handleWebhookUpdated(body.data) + break + + case 'user.deleted': + console.info('Processing user.deleted event') + result = await handleWebhookDeleted(body.data) + break + + default: + console.info(`Ignoring unsupported webhook type: ${body.type}`) + return NextResponse.json({ + message: `Webhook type ${body.type} is not supported`, + success: false, + }) + } - console.log(`Successfully marked user as deleted in PocketBase: ${clerkId}`) + return NextResponse.json(result) } catch (error) { - console.error( - `Failed to mark user as deleted in PocketBase: ${clerkId}`, - error + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + console.error('Error processing webhook:', error) + + return NextResponse.json( + { + error: 'Failed to process webhook', + message: errorMessage, + success: false, + }, + { status: 500 } ) - throw error } } diff --git a/src/lib/webhookUtils.ts b/src/lib/webhookUtils.ts index 0efe6ef..28c16b0 100644 --- a/src/lib/webhookUtils.ts +++ b/src/lib/webhookUtils.ts @@ -37,6 +37,11 @@ export async function verifyClerkWebhook( 'svix-timestamp': svix_timestamp, }) + if (!event) { + console.error('Invalid webhook signature') + return { success: false } + } + // If we reach here, the verification succeeded return { payload: JSON.parse(payload), success: true } } catch (error) { diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts index 9fc254d..0dd9c9f 100644 --- a/src/types/types_pocketbase.ts +++ b/src/types/types_pocketbase.ts @@ -44,7 +44,7 @@ export interface AppUser { isAdmin: boolean lastLogin?: string clerkId: string - organizations?: string + organizations?: Organization[] created: string updated: string // eslint-disable-next-line @typescript-eslint/no-explicit-any From 78991da876f1c5d8ef474db27f4bf494055a185f Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 11:16:02 +0200 Subject: [PATCH 41/73] feat(webhook): enhance Clerk webhook processing - Centralize webhook event handling for better organization - Improve error logging and response messages - Update permission elevation logic for security - Refactor user and organization event handling methods - Remove deprecated user authentication functions and related files --- .../services/clerk-sync/webhook-handler.ts | 129 +++++++----- .../services/pocketbase/assignmentService.ts | 17 +- .../services/pocketbase/equipmentService.ts | 15 +- .../services/pocketbase/organization/core.ts | 7 +- .../services/pocketbase/organization/index.ts | 3 - .../pocketbase/organization/internal.ts | 6 +- .../pocketbase/organization/membership.ts | 9 +- .../pocketbase/organization/security.ts | 6 +- .../services/pocketbase/projectService.ts | 11 +- .../actions/services/pocketbase/user/auth.ts | 45 ----- .../actions/services/pocketbase/user/core.ts | 191 ------------------ .../actions/services/pocketbase/user/index.ts | 34 ---- .../services/pocketbase/user/internal.ts | 96 --------- .../services/pocketbase/user/search.ts | 146 ------------- .../pocketbase/user/webhook-handlers.ts | 155 -------------- src/app/api/webhook/clerk/route.ts | 54 ----- 16 files changed, 120 insertions(+), 804 deletions(-) delete mode 100644 src/app/actions/services/pocketbase/user/auth.ts delete mode 100644 src/app/actions/services/pocketbase/user/core.ts delete mode 100644 src/app/actions/services/pocketbase/user/index.ts delete mode 100644 src/app/actions/services/pocketbase/user/internal.ts delete mode 100644 src/app/actions/services/pocketbase/user/search.ts delete mode 100644 src/app/actions/services/pocketbase/user/webhook-handlers.ts delete mode 100644 src/app/api/webhook/clerk/route.ts diff --git a/src/app/actions/services/clerk-sync/webhook-handler.ts b/src/app/actions/services/clerk-sync/webhook-handler.ts index 25d818b..a60a9af 100644 --- a/src/app/actions/services/clerk-sync/webhook-handler.ts +++ b/src/app/actions/services/clerk-sync/webhook-handler.ts @@ -1,66 +1,83 @@ +import * as userService from '@/app/actions/services/pocketbase/app-user/webhook-handlers' +import * as organizationService from '@/app/actions/services/pocketbase/organization/webhook-handlers' +import { WebhookProcessingResult } from '@/types/webhooks' /** * Central handler for processing Clerk webhook events * Routes to appropriate service methods based on event type */ -import { WebhookEvent } from "@clerk/nextjs/server"; -import { WebhookProcessingResult } from "@/types/webhooks"; -import * as organizationService from "../pocketbase/organizationService"; -import * as userService from "../pocketbase/userService"; +import { WebhookEvent } from '@clerk/nextjs/server' /** * Process a Clerk webhook event * @param {WebhookEvent} event - The webhook event from Clerk * @returns {Promise} Result of processing */ -export async function processWebhookEvent(event: WebhookEvent): Promise { - console.log(`Processing webhook event: ${event.type}`); - - try { - // Temporarily elevate permissions for webhook processing - const elevated = true; - - // Handle organization events - if (event.type === "organization.created") { - return await organizationService.handleWebhookCreated(event.data, elevated); - } - else if (event.type === "organization.updated") { - return await organizationService.handleWebhookUpdated(event.data, elevated); - } - else if (event.type === "organization.deleted") { - return await organizationService.handleWebhookDeleted(event.data, elevated); - } - - // Handle membership events - else if (event.type === "organizationMembership.created") { - return await organizationService.handleMembershipWebhookCreated(event.data, elevated); - } - else if (event.type === "organizationMembership.updated") { - return await organizationService.handleMembershipWebhookUpdated(event.data, elevated); - } - else if (event.type === "organizationMembership.deleted") { - return await organizationService.handleMembershipWebhookDeleted(event.data, elevated); - } - - // Handle user events - else if (event.type === "user.created") { - return await userService.handleWebhookCreated(event.data, elevated); - } - else if (event.type === "user.updated") { - return await userService.handleWebhookUpdated(event.data, elevated); - } - else if (event.type === "user.deleted") { - return await userService.handleWebhookDeleted(event.data, elevated); - } - - // Unknown event type - else { - return { success: false, message: `Unhandled webhook event type: ${event.type}` }; - } - } catch (error) { - console.error(`Error processing webhook ${event.type}:`, error); - return { - success: false, - message: `Error processing webhook: ${error instanceof Error ? error.message : "Unknown error"}` - }; - } -} \ No newline at end of file +export async function processWebhookEvent( + event: WebhookEvent +): Promise { + console.info(`Processing webhook event: ${event.type}`) + + try { + // Temporarily elevate permissions for webhook processing + const elevated = true + + // Handle organization events + if (event.type === 'organization.created') { + return await organizationService.handleWebhookCreated( + event.data, + elevated + ) + } else if (event.type === 'organization.updated') { + return await organizationService.handleWebhookUpdated( + event.data, + elevated + ) + } else if (event.type === 'organization.deleted') { + return await organizationService.handleWebhookDeleted( + event.data, + elevated + ) + } + + // Handle membership events + else if (event.type === 'organizationMembership.created') { + return await organizationService.handleMembershipWebhookCreated( + event.data, + elevated + ) + } else if (event.type === 'organizationMembership.updated') { + return await organizationService.handleMembershipWebhookUpdated( + event.data, + elevated + ) + } else if (event.type === 'organizationMembership.deleted') { + return await organizationService.handleMembershipWebhookDeleted( + event.data, + elevated + ) + } + + // Handle user events + else if (event.type === 'user.created') { + return await userService.handleWebhookCreated(event.data, elevated) + } else if (event.type === 'user.updated') { + return await userService.handleWebhookUpdated(event.data, elevated) + } else if (event.type === 'user.deleted') { + return await userService.handleWebhookDeleted(event.data, elevated) + } + + // Unknown event type + else { + return { + message: `Unhandled webhook event type: ${event.type}`, + success: false, + } + } + } catch (error) { + console.error(`Error processing webhook ${event.type}:`, error) + return { + message: `Error processing webhook: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} diff --git a/src/app/actions/services/pocketbase/assignmentService.ts b/src/app/actions/services/pocketbase/assignmentService.ts index c290e54..9bb2af9 100644 --- a/src/app/actions/services/pocketbase/assignmentService.ts +++ b/src/app/actions/services/pocketbase/assignmentService.ts @@ -8,10 +8,12 @@ import { validateOrganizationAccess, validateResourceAccess, createOrganizationFilter, - ResourceType, +} from '@/app/actions/services/pocketbase/securityUtils' +import { PermissionLevel, + ResourceType, SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +} from '@/app/actions/services/securyUtilsTools' import { Assignment, ListOptions, ListResult } from '@/types/types_pocketbase' /** @@ -64,7 +66,10 @@ export async function getAssignmentsList( } = options // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) + const filter = await createOrganizationFilter( + organizationId, + additionalFilter + ) return await pb.collection('assignments').getList(page, perPage, { ...rest, @@ -182,7 +187,7 @@ export async function getUserAssignments( // Include organization filter for security return await pb.collection('assignments').getFullList({ expand: 'equipmentId,assignedToProjectId', - filter: createOrganizationFilter( + filter: await createOrganizationFilter( organizationId, `assignedToUserId="${userId}"` ), @@ -218,7 +223,7 @@ export async function getProjectAssignments( // Include organization filter for security return await pb.collection('assignments').getFullList({ expand: 'equipmentId,assignedToUserId', - filter: createOrganizationFilter( + filter: await createOrganizationFilter( organizationId, `assignedToProjectId=${projectId}` ), @@ -443,7 +448,7 @@ export async function getEquipmentAssignmentHistory( // Include organization filter for security return await pb.collection('assignments').getFullList({ expand: 'assignedToUserId,assignedToProjectId', - filter: createOrganizationFilter( + filter: await createOrganizationFilter( organizationId, `equipmentId="${equipmentId}"` ), diff --git a/src/app/actions/services/pocketbase/equipmentService.ts b/src/app/actions/services/pocketbase/equipmentService.ts index 32aec81..6a38c3e 100644 --- a/src/app/actions/services/pocketbase/equipmentService.ts +++ b/src/app/actions/services/pocketbase/equipmentService.ts @@ -8,10 +8,12 @@ import { validateOrganizationAccess, validateResourceAccess, createOrganizationFilter, - ResourceType, +} from '@/app/actions/services/pocketbase/securityUtils' +import { PermissionLevel, + ResourceType, SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +} from '@/app/actions/services/securyUtilsTools' import { Equipment, ListOptions, ListResult } from '@/types/types_pocketbase' /** @@ -57,7 +59,7 @@ export async function getEquipmentByCode( } // Apply organization filter for security - const filter = createOrganizationFilter( + const filter = await createOrganizationFilter( organizationId, `qrNfcCode="${qrNfcCode}"` ) @@ -94,7 +96,10 @@ export async function getEquipmentList( } = options // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) + const filter = await createOrganizationFilter( + organizationId, + additionalFilter + ) return await pb.collection('equipment').getList(page, perPage, { ...rest, @@ -268,7 +273,7 @@ export async function getChildEquipment( } // Apply organization filter for security - fixed field name - const filter = createOrganizationFilter( + const filter = await createOrganizationFilter( organizationId, `parentEquipmentId="${parentId}"` ) diff --git a/src/app/actions/services/pocketbase/organization/core.ts b/src/app/actions/services/pocketbase/organization/core.ts index d4d7c6b..30a07dc 100644 --- a/src/app/actions/services/pocketbase/organization/core.ts +++ b/src/app/actions/services/pocketbase/organization/core.ts @@ -12,9 +12,11 @@ import { import { validateCurrentUser, validateOrganizationAccess, +} from '@/app/actions/services/pocketbase/securityUtils' +import { PermissionLevel, SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +} from '@/app/actions/services/securyUtilsTools' import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' /** @@ -80,7 +82,8 @@ export async function getOrganizationByClerkId( throw new SecurityError('User does not belong to this organization') } - return organization + // todo : fix types + return organization as Organization } catch (error) { if (error instanceof SecurityError) { throw error diff --git a/src/app/actions/services/pocketbase/organization/index.ts b/src/app/actions/services/pocketbase/organization/index.ts index d3a6cd8..a594e03 100644 --- a/src/app/actions/services/pocketbase/organization/index.ts +++ b/src/app/actions/services/pocketbase/organization/index.ts @@ -34,6 +34,3 @@ export { handleMembershipWebhookUpdated, handleMembershipWebhookDeleted, } from '@/app/actions/services/pocketbase/organization/webhook-handlers' - -// Internal functions for direct access when needed -export { getByClerkId } from '@/app/actions/services/pocketbase/organization/internal' diff --git a/src/app/actions/services/pocketbase/organization/internal.ts b/src/app/actions/services/pocketbase/organization/internal.ts index 7dcb044..f046420 100644 --- a/src/app/actions/services/pocketbase/organization/internal.ts +++ b/src/app/actions/services/pocketbase/organization/internal.ts @@ -35,6 +35,7 @@ export async function _createOrganization( const newOrg = await pb.collection('Organization').create(data) console.info('New Organization created:', newOrg) + // todo : fix types return newOrg } catch (error) { console.error('Error creating organization:', error) @@ -62,6 +63,7 @@ export async function _updateOrganization( const updatedOrg = await pb.collection('Organization').update(id, data) console.info('Organization updated:', updatedOrg) + // todo : fix types return updatedOrg } catch (error) { console.error('Error updating organization:', error) @@ -110,7 +112,9 @@ export async function getOrganizationByClerkId( .getFirstListItem(`clerkId="${clerkId}"`) console.info('Organization found:', org) - return org + + // todo : fix types + return org as Organization } catch (error) { // Check if this is a "not found" error if ( diff --git a/src/app/actions/services/pocketbase/organization/membership.ts b/src/app/actions/services/pocketbase/organization/membership.ts index 05eebb8..8e58826 100644 --- a/src/app/actions/services/pocketbase/organization/membership.ts +++ b/src/app/actions/services/pocketbase/organization/membership.ts @@ -4,11 +4,11 @@ import { getPocketBase, handlePocketBaseError, } from '@/app/actions/services/pocketbase/baseService' +import { validateOrganizationAccess } from '@/app/actions/services/pocketbase/securityUtils' import { - validateOrganizationAccess, PermissionLevel, SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +} from '@/app/actions/services/securyUtilsTools' import { AppUser } from '@/types/types_pocketbase' /** @@ -86,7 +86,9 @@ export async function _removeUserFromOrganization( } // Remove organization from user's organizations list - const updatedOrgs = currentOrgs.filter(orgId => orgId !== organizationId) + const updatedOrgs = currentOrgs.filter( + (orgId: string) => orgId !== organizationId + ) // Update user with new organizations list return await pb.collection('users').update(userId, { @@ -172,6 +174,7 @@ export async function getOrganizationUsers( filter: `organizations ~ "${organizationId}"`, }) + // todo : fix types return result.items } catch (error) { if (error instanceof SecurityError) { diff --git a/src/app/actions/services/pocketbase/organization/security.ts b/src/app/actions/services/pocketbase/organization/security.ts index adf516b..0fabfd9 100644 --- a/src/app/actions/services/pocketbase/organization/security.ts +++ b/src/app/actions/services/pocketbase/organization/security.ts @@ -1,10 +1,8 @@ 'use server' import { getUserOrganizations } from '@/app/actions/services/pocketbase/organization/core' -import { - validateCurrentUser, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +import { validateCurrentUser } from '@/app/actions/services/pocketbase/securityUtils' +import { SecurityError } from '@/app/actions/services/securyUtilsTools' /** * Organization-specific security functions diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts index 57625b0..da7153a 100644 --- a/src/app/actions/services/pocketbase/projectService.ts +++ b/src/app/actions/services/pocketbase/projectService.ts @@ -8,10 +8,12 @@ import { validateOrganizationAccess, validateResourceAccess, createOrganizationFilter, - ResourceType, +} from '@/app/actions/services/pocketbase/securityUtils' +import { PermissionLevel, + ResourceType, SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' +} from '@/app/actions/services/securyUtilsTools' import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' /** @@ -60,7 +62,10 @@ export async function getProjectsList( } = options // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) + const filter = await createOrganizationFilter( + organizationId, + additionalFilter + ) return await pb.collection('projects').getList(page, perPage, { ...rest, diff --git a/src/app/actions/services/pocketbase/user/auth.ts b/src/app/actions/services/pocketbase/user/auth.ts deleted file mode 100644 index b7e616e..0000000 --- a/src/app/actions/services/pocketbase/user/auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { User } from '@/types/types_pocketbase' - -/** - * Authentication-related functions for users - */ - -/** - * Update user's last login time - * This is typically called during authentication flows - */ -export async function updateUserLastLogin(id: string): Promise { - try { - // Since this is called during authentication, - // we'll just verify the user exists rather than permissions - const user = await validateCurrentUser(id) - - if (!user) { - throw new SecurityError('User not found') - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').update(id, { - lastLogin: new Date().toISOString(), - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.updateUserLastLogin') - } -} diff --git a/src/app/actions/services/pocketbase/user/core.ts b/src/app/actions/services/pocketbase/user/core.ts deleted file mode 100644 index 9447043..0000000 --- a/src/app/actions/services/pocketbase/user/core.ts +++ /dev/null @@ -1,191 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - validateOrganizationAccess, - validateResourceAccess, -} from '@/app/actions/services/pocketbase/securityUtils' -import { - _updateUser, - _createUser, - _deleteUser, -} from '@/app/actions/services/pocketbase/user/internal' -import { - PermissionLevel, - ResourceType, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { AppUser } from '@/types/types_pocketbase' - -/** - * Core user operations with security validations - */ - -/** - * Get a single user by ID with security validation - */ -export async function getUser(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'UserService.getUser') - } -} - -/** - * Get current authenticated user profile - */ -export async function getCurrentUser(): Promise { - try { - // This function automatically validates the current user - return await validateCurrentUser() - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getCurrentUser') - } -} - -/** - * Get a user by Clerk ID - typically used during authentication - */ -export async function getUserByClerkId(clerkId: string): Promise { - // This is primarily used during authentication flows where - // standard security checks aren't possible yet. - // However, requests should still come from server-side code only. - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUserByClerkId') - } -} - -/** - * Create a new user with security checks - * This is typically controlled access for admins only - */ -export async function createUser( - organizationId: string, - data: AppUser, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission to create users - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - } - - // Ensure organization ID is set correctly with the proper field name - return await _createUser({ - ...data, - organizations: [organizationId], // Force the correct organization ID using the relation field - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.createUser') - } -} - -/** - * Update a user with security checks - */ -export async function updateUser( - id: string, - data: AppUser, - elevated = false -): Promise { - try { - if (!elevated) { - // Get current authenticated user - const currentUser = await validateCurrentUser() - - // Different permission checks based on who is being updated - if (id !== currentUser.id) { - // Updating someone else requires ADMIN permission - await validateResourceAccess( - ResourceType.USER, - id, - PermissionLevel.ADMIN - ) - } else { - // Users can update their own basic info - // But for role changes, they'd still need admin rights - if (data.role || data.isAdmin !== undefined) { - // If trying to change role or admin status, require admin permission - // Get the user's organization ID - handling possible multiple organizations - const userOrgs = currentUser.organizations - - if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { - throw new SecurityError('User does not belong to any organization') - } - - // Use the first organization for permission check - const primaryOrgId = userOrgs[0].id - await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) - } - } - } - - // Security: never allow changing certain fields - const sanitizedData = { ...data } - // Don't allow org changes directly - use proper organization management functions - delete (sanitizedData as Record).organizations - - if (!elevated) { - // Only elevated calls (webhooks) can change the clerkId - delete (sanitizedData as Record).clerkId - } - - return await _updateUser(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.updateUser') - } -} - -/** - * Delete a user with admin permission check - */ -export async function deleteUser( - id: string, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for user deletion - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) - } - - return await _deleteUser(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.deleteUser') - } -} diff --git a/src/app/actions/services/pocketbase/user/index.ts b/src/app/actions/services/pocketbase/user/index.ts deleted file mode 100644 index 98a6ddf..0000000 --- a/src/app/actions/services/pocketbase/user/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * User service - public exports - */ - -// Core operations -export { - getUser, - getCurrentUser, - getUserByClerkId, - createUser, - updateUser, - deleteUser, -} from '@/app/actions/services/pocketbase/user/core' - -// Search functions -export { - getUsersList, - getUsersByOrganization, - getUserCount, - searchUsers, -} from '@/app/actions/services/pocketbase/user/search' - -// Authentication functions -export { updateUserLastLogin } from '@/app/actions/services/pocketbase/user/auth' - -// Webhook handlers -export { - handleWebhookCreated, - handleWebhookUpdated, - handleWebhookDeleted, -} from '@/app/actions/services/pocketbase/user/webhook-handlers' - -// Internal functions that might be used by other services -export { getByClerkId } from '@/app/actions/services/pocketbase/user/internal' diff --git a/src/app/actions/services/pocketbase/user/internal.ts b/src/app/actions/services/pocketbase/user/internal.ts deleted file mode 100644 index a47de26..0000000 --- a/src/app/actions/services/pocketbase/user/internal.ts +++ /dev/null @@ -1,96 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { User } from '@/types/types_pocketbase' - -/** - * Internal methods for user management - * These methods have no security checks and should only be called - * from secured public API methods - */ - -/** - * Internal: Update user without security checks - * @param id User ID - * @param data User data - */ -export async function _updateUser( - id: string, - data: Partial -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').update(id, data) - } catch (error) { - return handlePocketBaseError(error, 'UserService._updateUser') - } -} - -/** - * Internal: Create user without security checks - * @param data User data - */ -export async function _createUser(data: Partial): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('users').create(data) - } catch (error) { - return handlePocketBaseError(error, 'UserService._createUser') - } -} - -/** - * Internal: Delete user without security checks - * @param id User ID - */ -export async function _deleteUser(id: string): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('users').delete(id) - return true - } catch (error) { - handlePocketBaseError(error, 'UserService._deleteUser') - return false - } -} - -/** - * Get a user by Clerk ID - * @param clerkId Clerk user ID - * @returns User record or null if not found - */ -export async function getByClerkId(clerkId: string): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const user = await pb - .collection('users') - .getFirstListItem(`clerkId="${clerkId}"`) - return user - } catch (error) { - // If user not found, return null instead of throwing - if (error instanceof Error && error.message.includes('404')) { - return null - } - console.error('Error fetching user by clerk ID:', error) - return null - } -} diff --git a/src/app/actions/services/pocketbase/user/search.ts b/src/app/actions/services/pocketbase/user/search.ts deleted file mode 100644 index 1426f95..0000000 --- a/src/app/actions/services/pocketbase/user/search.ts +++ /dev/null @@ -1,146 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - createOrganizationFilter, - PermissionLevel, - SecurityError, -} from '@/app/actions/services/pocketbase/securityUtils' -import { ListOptions, ListResult, User } from '@/types/types_pocketbase' - -/** - * Search and listing functions for users - */ - -/** - * Get users list with pagination and security checks - */ -export async function getUsersList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - needs at least READ permission - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = createOrganizationFilter(organizationId, additionalFilter) - - return await pb.collection('users').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUsersList') - } -} - -/** - * Get all users for an organization with security checks - */ -export async function getUsersByOrganization( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with this organization in their organizations relation - return await pb.collection('users').getFullList({ - filter: `organizations ~ "${organizationId}"`, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUsersByOrganization') - } -} - -/** - * Get the count of users in an organization - */ -export async function getUserCount(organizationId: string): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with this organization in their organizations relation - const result = await pb.collection('users').getList(1, 1, { - filter: `organizations ~ "${organizationId}"`, - skipTotal: false, - }) - - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.getUserCount') - } -} - -/** - * Search for users in the organization - */ -export async function searchUsers( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with search conditions - return await pb.collection('users').getFullList({ - filter: pb.filter( - 'organizations ~ {:orgId} && (name ~ {:query} || email ~ {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'UserService.searchUsers') - } -} diff --git a/src/app/actions/services/pocketbase/user/webhook-handlers.ts b/src/app/actions/services/pocketbase/user/webhook-handlers.ts deleted file mode 100644 index 24f0f43..0000000 --- a/src/app/actions/services/pocketbase/user/webhook-handlers.ts +++ /dev/null @@ -1,155 +0,0 @@ -'use server' - -import { - createUser, - updateUser, - deleteUser, -} from '@/app/actions/services/pocketbase/user/core' -import { getByClerkId } from '@/app/actions/services/pocketbase/user/internal' -import { ClerkUserWebhookData, WebhookProcessingResult } from '@/types/webhooks' - -/** - * Webhook handlers for user events from Clerk - */ - -/** - * Handles a webhook event for user creation - * @param {ClerkUserWebhookData} data - User data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookCreated( - data: ClerkUserWebhookData, - elevated = true -): Promise { - try { - // Check if already exists - const existing = await getByClerkId(data.id) - if (existing) { - return { - message: `User ${data.id} already exists`, - success: true, - } - } - - // Get primary email if available - let email = '' - if (data.email_addresses && data.email_addresses.length > 0) { - email = data.email_addresses[0].email_address - } - - // Create new user - normally we'd include an organization ID, but - // for webhook-created users, we'll wait for the organization membership event - await createUser( - '', // Leave empty for now, will be set when user joins an organization - { - avatar: data.profile_image_url || data.image_url, - clerkId: data.id, - email, - name: `${data.first_name || ''} ${data.last_name || ''}`.trim(), - role: 'member', - verified: true, - }, - elevated - ) - - return { - message: `Created user ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process user creation webhook:', error) - return { - message: `Failed to process user creation: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for user update - * @param {ClerkUserWebhookData} data - User data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookUpdated( - data: ClerkUserWebhookData, - elevated = true -): Promise { - try { - // Find existing user - const existing = await getByClerkId(data.id) - if (!existing) { - return { - message: `User ${data.id} not found`, - success: false, - } - } - - // Get primary email if available - let email = existing.email // Default to existing - if (data.email_addresses && data.email_addresses.length > 0) { - email = data.email_addresses[0].email_address - } - - // Update user - await updateUser( - existing.id, - { - avatar: data.profile_image_url || data.image_url || existing.avatar, - email, - name: - `${data.first_name || ''} ${data.last_name || ''}`.trim() || - existing.name, - }, - elevated - ) - - return { - message: `Updated user ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process user update webhook:', error) - return { - message: `Failed to process user update: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for user deletion - * @param {ClerkUserWebhookData} data - User deletion data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookDeleted( - data: ClerkUserWebhookData, - elevated = true -): Promise { - try { - // Find existing user - const existing = await getByClerkId(data.id) - if (!existing) { - return { - message: `User ${data.id} already deleted or not found`, - success: true, - } - } - - // Delete user - await deleteUser(existing.id, elevated) - - return { - message: `Deleted user ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process user deletion webhook:', error) - return { - message: `Failed to process user deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} diff --git a/src/app/api/webhook/clerk/route.ts b/src/app/api/webhook/clerk/route.ts deleted file mode 100644 index 38151e5..0000000 --- a/src/app/api/webhook/clerk/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' -import { headers } from 'next/headers' -import { NextResponse } from 'next/server' -import { Webhook } from 'svix' - -/** - * Webhook handler for Clerk events - * Verifies the webhook signature and processes events - */ -export async function POST(req: Request) { - const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET - - if (!WEBHOOK_SECRET) { - console.error('Missing CLERK_WEBHOOK_SECRET') - return new NextResponse('Webhook secret missing', { status: 500 }) - } - - // Get the signature and timestamp from the headers - const headerPayload = headers() - const svix_id = headerPayload.get('svix-id') - const svix_timestamp = headerPayload.get('svix-timestamp') - const svix_signature = headerPayload.get('svix-signature') - - // If there are no headers, error out - if (!svix_id || !svix_timestamp || !svix_signature) { - return new NextResponse('Error: Missing svix headers', { status: 400 }) - } - - // Get the body - const payload = await req.json() - const body = JSON.stringify(payload) - - // Create a new Svix instance with your secret - const webhook = new Webhook(WEBHOOK_SECRET) - - try { - // Verify the payload with the headers - const event = webhook.verify(body, { - 'svix-id': svix_id, - 'svix-signature': svix_signature, - 'svix-timestamp': svix_timestamp, - }) - - console.log(`Webhook received: ${event.type}`) - - // Process the event using our centralized handler - const result = await processWebhookEvent(event) - - return NextResponse.json(result) - } catch (err) { - console.error('Error verifying webhook:', err) - return new NextResponse('Error verifying webhook', { status: 400 }) - } -} From 802a5063f365416f7dff0f229f8af99dfcca087e Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 11:20:35 +0200 Subject: [PATCH 42/73] feat(cache): remove secure cache service - Delete the SecureCache class and its related methods - Clean up unused imports in reconciliation and syncMiddleware files - Update organization ID handling in createAppUser function - Change console.log to console.info for better logging clarity - Add type fixes as TODO comments where necessary --- .../services/clerk-sync/cacheService.ts | 201 ------------------ .../services/clerk-sync/reconciliation.ts | 7 +- .../services/clerk-sync/syncMiddleware.ts | 2 + .../services/pocketbase/app-user/core.ts | 13 +- .../services/pocketbase/app-user/internal.ts | 17 +- .../services/pocketbase/app-user/search.ts | 2 +- 6 files changed, 24 insertions(+), 218 deletions(-) delete mode 100644 src/app/actions/services/clerk-sync/cacheService.ts diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts deleted file mode 100644 index 4679fb9..0000000 --- a/src/app/actions/services/clerk-sync/cacheService.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { createHash as nodeCreateHash } from 'crypto' - -/** - * Type for cacheable data to avoid using any - */ -type CacheableValue = unknown - -/** - * Interface for cache entries with security features - */ -interface SecureCacheEntry { - data: T - timestamp: number - userId: string - hash: string -} - -/** - * Secure caching service with user-specific isolation and integrity validation - * Implements security best practices to prevent cache manipulation attacks - */ -class SecureCache { - private readonly cache: Map> - private readonly secretKey: string - - constructor() { - this.cache = new Map() - - // Use the environment secret or generate a random one per instance - // This makes cache manipulation attacks significantly harder - this.secretKey = - process.env.CACHE_SECRET || - Array.from({ length: 32 }, () => - Math.floor(Math.random() * 256) - .toString(16) - .padStart(2, '0') - ).join('') - } - - /** - * Creates a cryptographic hash to verify data integrity - * Compatible with Edge runtime - * - * @param data The data to hash - * @param userId The user ID to include in the hash - * @returns The generated hash - */ - private createHash(data: CacheableValue, userId: string): string { - const content = JSON.stringify(data) + userId + this.secretKey - - // Use Web Crypto API which is available in Edge runtime - if (typeof crypto !== 'undefined' && crypto.subtle) { - // Convert string to ArrayBuffer - const encoder = new TextEncoder() - const dataBuffer = encoder.encode(content) - - // Create a promise that will be resolved synchronously - let hashHex = '' - - // Use subtle.digest synchronously (in a hacky way for this context) - const p = crypto.subtle.digest('SHA-256', dataBuffer).then(hashBuffer => { - // Convert buffer to byte array - const hashArray = Array.from(new Uint8Array(hashBuffer)) - // Convert bytes to hex string - hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - }) - - if (hashHex) return hashHex - return this.simpleHash(content) - } - - return this.simpleHash(content) - } - - /** - * Simple hash function as fallback - * Not as secure as crypto but works in all environments - */ - private simpleHash(str: string): string { - let hash = 0 - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i) - hash = (hash << 5) - hash + char - hash = hash & hash // Convert to 32bit integer - } - // Convert to hex string and ensure it's 64 chars long for consistency - return Math.abs(hash).toString(16).padStart(64, '0') - } - - /** - * Stores a value in the cache with security validation - * - * @param key The cache key - * @param value The value to store - * @param userId The user ID for isolation and validation - * @param ttl Optional TTL in milliseconds (defaults to 2 minutes) - */ - set( - key: string, - value: CacheableValue, - userId: string, - ttl: number = 2 * 60 * 1000 - ): void { - // Create an integrity hash that binds the data to this specific user - const integrityHash = this.createHash(value, userId) - - this.cache.set(key, { - data: value, - hash: integrityHash, - timestamp: Date.now(), - userId, - }) - - // Set automatic expiration for this entry - if (ttl > 0) { - setTimeout(() => { - this.cache.delete(key) - }, ttl) - } - } - - /** - * Retrieves a value from the cache with security checks - * - * @param key The cache key - * @param userId The user ID for isolation and validation - * @param maxAge Optional maximum age in milliseconds - * @returns The cached value or null if not found/invalid - */ - get( - key: string, - userId: string, - maxAge: number = 2 * 60 * 1000 - ): CacheableValue | null { - const entry = this.cache.get(key) - - // No entry found - if (!entry) { - return null - } - - // Check if entry has expired - if (maxAge > 0 && Date.now() - entry.timestamp > maxAge) { - this.cache.delete(key) - return null - } - - // Enforce user isolation - users can only access their own cache entries - if (entry.userId !== userId) { - return null - } - - // Verify data integrity using the hash - const expectedHash = this.createHash(entry.data, userId) - if (entry.hash !== expectedHash) { - // Hash mismatch indicates potential tampering - remove the entry - this.cache.delete(key) - console.warn(`Cache integrity violation detected for key: ${key}`) - return null - } - - return entry.data - } - - /** - * Invalidates a cache entry - * - * @param key The cache key to invalidate - * @param userId Optional user ID for additional security - */ - invalidate(key: string, userId?: string): void { - // If userId is provided, only allow invalidation of that user's entries - if (userId) { - const entry = this.cache.get(key) - if (entry && entry.userId === userId) { - this.cache.delete(key) - } - } else { - this.cache.delete(key) - } - } - - /** - * Clears all cache entries - use with caution - */ - clear(): void { - this.cache.clear() - } - - /** - * Gets the current size of the cache - * - * @returns Number of entries in the cache - */ - size(): number { - return this.cache.size - } -} - -// Singleton instance for app-wide use -export const secureCache = new SecureCache() diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts index 688f1d3..67c10e2 100644 --- a/src/app/actions/services/clerk-sync/reconciliation.ts +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -2,14 +2,13 @@ // Import types for Clerk entities import type { Organization, User } from '@clerk/nextjs/server' -// src/app/actions/services/clerk-sync/reconciliation.ts -import { clerkClient } from '@clerk/nextjs/server' - import { syncUserToPocketBase, syncOrganizationToPocketBase, linkUserToOrganization, -} from './syncService' +} from '@/app/actions/services/clerk-sync/syncService' +// src/app/actions/services/clerk-sync/reconciliation.ts +import { clerkClient } from '@clerk/nextjs/server' /** * Full reconciliation between Clerk and PocketBase diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts index d8a5f56..1cbbdd4 100644 --- a/src/app/actions/services/clerk-sync/syncMiddleware.ts +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -86,6 +86,8 @@ export async function ensureUserAndOrgSync( const clerkOrg = await clerkClientInstance.organizations.getOrganization({ organizationId: clerkOrgId, }) + + // todo : fix types await syncOrganizationToPocketBase(clerkOrg) // 4. Ensure the user-organization relationship exists diff --git a/src/app/actions/services/pocketbase/app-user/core.ts b/src/app/actions/services/pocketbase/app-user/core.ts index 0077d55..99adbc9 100644 --- a/src/app/actions/services/pocketbase/app-user/core.ts +++ b/src/app/actions/services/pocketbase/app-user/core.ts @@ -13,10 +13,12 @@ import { validateCurrentUser, validateOrganizationAccess, validateResourceAccess, - SecurityError, - ResourceType, - PermissionLevel, } from '@/app/actions/services/pocketbase/securityUtils' +import { + PermissionLevel, + ResourceType, + SecurityError, +} from '@/app/actions/services/securyUtilsTools' import { AppUser } from '@/types/types_pocketbase' /** @@ -107,10 +109,11 @@ export async function createAppUser( await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) } + // todo : fix types // Ensure organization ID is set correctly with the proper field name return await _createAppUser({ ...data, - organizations: organizationId ? [organizationId] : [], // Force the correct organization ID using the relation field + organizations: organizationId ? [{ id: organizationId }] : [], // Force the correct organization ID using the relation field }) } catch (error) { if (error instanceof SecurityError) { @@ -148,7 +151,7 @@ export async function updateAppUser( if (data.role || data.isAdmin !== undefined) { // If trying to change role or admin status, require admin permission // Get the user's organization ID - handling possible multiple organizations - const userOrgs = currentAppUser.expand?.organizations + const userOrgs = currentAppUser.organizations if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { throw new SecurityError('User does not belong to any organization') diff --git a/src/app/actions/services/pocketbase/app-user/internal.ts b/src/app/actions/services/pocketbase/app-user/internal.ts index fb5f113..9591df4 100644 --- a/src/app/actions/services/pocketbase/app-user/internal.ts +++ b/src/app/actions/services/pocketbase/app-user/internal.ts @@ -44,7 +44,7 @@ export async function _createAppUser(data: Partial): Promise { throw new Error('Failed to connect to PocketBase') } - console.log('Creating new AppUser with data:', data) + console.info('Creating new AppUser with data:', data) // Make sure clerkId is included if (!data.clerkId) { @@ -52,9 +52,10 @@ export async function _createAppUser(data: Partial): Promise { } const newUser = await pb.collection('AppUser').create(data) - console.log('New AppUser created:', newUser) + console.info('New AppUser created:', newUser) - return newUser + // todo : fix types + return newUser as unknown as AppUser } catch (error) { console.error('Error creating app user:', error) throw error @@ -92,7 +93,7 @@ export async function getByClerkId(clerkId: string): Promise { throw new Error('Failed to connect to PocketBase') } - console.log(`Searching for AppUser with clerkId: ${clerkId}`) + console.info(`Searching for AppUser with clerkId: ${clerkId}`) // Try to find the user try { @@ -100,15 +101,17 @@ export async function getByClerkId(clerkId: string): Promise { .collection('AppUser') .getFirstListItem(`clerkId="${clerkId}"`) - console.log('User found:', user) - return user + console.info('User found:', user) + + // todo : fix types + return user as unknown as AppUser } catch (error) { // Check if this is a "not found" error if ( error instanceof Error && (error.message.includes('404') || error.message.includes('not found')) ) { - console.log(`No user found with clerkId: ${clerkId}`) + console.info(`No user found with clerkId: ${clerkId}`) return null } // Otherwise rethrow the error diff --git a/src/app/actions/services/pocketbase/app-user/search.ts b/src/app/actions/services/pocketbase/app-user/search.ts index e9cd028..ec5fc10 100644 --- a/src/app/actions/services/pocketbase/app-user/search.ts +++ b/src/app/actions/services/pocketbase/app-user/search.ts @@ -4,8 +4,8 @@ import { getPocketBase, handlePocketBaseError, } from '@/app/actions/services/pocketbase/baseService' +import { validateOrganizationAccess } from '@/app/actions/services/pocketbase/securityUtils' import { - validateOrganizationAccess, PermissionLevel, SecurityError, } from '@/app/actions/services/securyUtilsTools' From 3d95c94ee54c8dce172f45b8dd49b9201ae1ba10 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 11:27:52 +0200 Subject: [PATCH 43/73] feat(core): add secure caching service and middleware updates - Implement secure caching with user-specific isolation - Add cryptographic hash for data integrity validation - Create methods for setting, getting, invalidating, and clearing cache entries - Update middleware to use NextResponse for redirects instead of Response - Ensure proper handling of protected routes and user authentication checks --- .../services/clerk-sync/cacheService.ts | 204 ++++++++++++++++++ src/middleware.ts | 14 +- 2 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/app/actions/services/clerk-sync/cacheService.ts diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts new file mode 100644 index 0000000..c020921 --- /dev/null +++ b/src/app/actions/services/clerk-sync/cacheService.ts @@ -0,0 +1,204 @@ +import { createHash as nodeCreateHash } from 'crypto' + +/** + * Type for cacheable data to avoid using any + */ +type CacheableValue = unknown + +/** + * Interface for cache entries with security features + */ +interface SecureCacheEntry { + data: T + timestamp: number + userId: string + hash: string +} + +/** + * Secure caching service with user-specific isolation and integrity validation + * Implements security best practices to prevent cache manipulation attacks + */ +class SecureCache { + private readonly cache: Map> + private readonly secretKey: string + + constructor() { + this.cache = new Map() + + // Use the environment secret or generate a random one per instance + // This makes cache manipulation attacks significantly harder + this.secretKey = + process.env.CACHE_SECRET || + Array.from({ length: 32 }, () => + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0') + ).join('') + } + + /** + * Creates a cryptographic hash to verify data integrity + * Compatible with Edge runtime + * + * @param data The data to hash + * @param userId The user ID to include in the hash + * @returns The generated hash + */ + private createHash(data: CacheableValue, userId: string): string { + const content = JSON.stringify(data) + userId + this.secretKey + + // Use Web Crypto API which is available in Edge runtime + if (typeof crypto !== 'undefined' && crypto.subtle) { + // Convert string to ArrayBuffer + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(content) + + // Create a promise that will be resolved synchronously + let hashHex = '' + // Use subtle.digest which returns a Promise + try { + crypto.subtle.digest('SHA-256', dataBuffer).then(hashBuffer => { + // Convert buffer to byte array + const hashArray = Array.from(new Uint8Array(hashBuffer)) + // Convert bytes to hex string + hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + }) + } catch (error) { + console.error('Crypto digest failed:', error) + } + + if (hashHex) return hashHex + return this.simpleHash(content) + } + + return this.simpleHash(content) + } + + /** + * Simple hash function as fallback + * Not as secure as crypto but works in all environments + */ + private simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + // Convert to hex string and ensure it's 64 chars long for consistency + return Math.abs(hash).toString(16).padStart(64, '0') + } + + /** + * Stores a value in the cache with security validation + * + * @param key The cache key + * @param value The value to store + * @param userId The user ID for isolation and validation + * @param ttl Optional TTL in milliseconds (defaults to 2 minutes) + */ + set( + key: string, + value: CacheableValue, + userId: string, + ttl: number = 2 * 60 * 1000 + ): void { + // Create an integrity hash that binds the data to this specific user + const integrityHash = this.createHash(value, userId) + + this.cache.set(key, { + data: value, + hash: integrityHash, + timestamp: Date.now(), + userId, + }) + + // Set automatic expiration for this entry + if (ttl > 0) { + setTimeout(() => { + this.cache.delete(key) + }, ttl) + } + } + + /** + * Retrieves a value from the cache with security checks + * + * @param key The cache key + * @param userId The user ID for isolation and validation + * @param maxAge Optional maximum age in milliseconds + * @returns The cached value or null if not found/invalid + */ + get( + key: string, + userId: string, + maxAge: number = 2 * 60 * 1000 + ): CacheableValue | null { + const entry = this.cache.get(key) + + // No entry found + if (!entry) { + return null + } + + // Check if entry has expired + if (maxAge > 0 && Date.now() - entry.timestamp > maxAge) { + this.cache.delete(key) + return null + } + + // Enforce user isolation - users can only access their own cache entries + if (entry.userId !== userId) { + return null + } + + // Verify data integrity using the hash + const expectedHash = this.createHash(entry.data, userId) + if (entry.hash !== expectedHash) { + // Hash mismatch indicates potential tampering - remove the entry + this.cache.delete(key) + console.warn(`Cache integrity violation detected for key: ${key}`) + return null + } + + return entry.data + } + + /** + * Invalidates a cache entry + * + * @param key The cache key to invalidate + * @param userId Optional user ID for additional security + */ + invalidate(key: string, userId?: string): void { + // If userId is provided, only allow invalidation of that user's entries + if (userId) { + const entry = this.cache.get(key) + if (entry && entry.userId === userId) { + this.cache.delete(key) + } + } else { + this.cache.delete(key) + } + } + + /** + * Clears all cache entries - use with caution + */ + clear(): void { + this.cache.clear() + } + + /** + * Gets the current size of the cache + * + * @returns Number of entries in the cache + */ + size(): number { + return this.cache.size + } +} + +// Singleton instance for app-wide use +export const secureCache = new SecureCache() diff --git a/src/middleware.ts b/src/middleware.ts index bc46a2c..7cec24b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -5,6 +5,7 @@ import { clerkMiddleware, createRouteMatcher, } from '@clerk/nextjs/server' +import { NextResponse } from 'next/server' const isProtectedRoute = createRouteMatcher(['/app(.*)']) @@ -38,19 +39,22 @@ export default clerkMiddleware(async (auth, req) => { } const authAwaited = await auth() - if (!authAwaited.userId) { - return Response.redirect(new URL('/sign-in', req.url)) + + if (isProtectedRoute(req)) { + if (!authAwaited.userId) { + return NextResponse.redirect(new URL('/sign-in', req.url)) + } } if (isAdminRoute(req)) { const userData = authAwaited.orgRole if (userData !== 'admin') { - return Response.redirect(new URL('/', req.url)) + return NextResponse.redirect(new URL('/', req.url)) } } if (!authAwaited.orgId) { - return Response.redirect(new URL('/onboarding', req.url)) + return NextResponse.redirect(new URL('/onboarding', req.url)) } // Synchronize user and organization data @@ -74,7 +78,7 @@ export default clerkMiddleware(async (auth, req) => { isProtectedRoute(req) && !userMetadata?.publicMetadata?.hasCompletedOnboarding ) { - return Response.redirect(new URL('/onboarding', req.url)) + return NextResponse.redirect(new URL('/onboarding', req.url)) } if (isProtectedRoute(req)) await auth.protect() From 98edfae51fd6bc499ba9623700aac3f6602b14a3 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 11:31:24 +0200 Subject: [PATCH 44/73] feat(middleware): enhance authentication flow - Allow public routes without checks - Redirect to sign-in for unauthenticated protected routes - Redirect to home for non-admin users on admin routes - Redirect to onboarding if no organization is selected - Sync user and organization data only when both are available - Check onboarding status and redirect if incomplete - Handle errors gracefully with console logging and safe fallbacks --- src/middleware.ts | 74 ++++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index 7cec24b..3241d99 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -34,54 +34,62 @@ export default clerkMiddleware(async (auth, req) => { return } + // Always allow public routes without additional checks if (isPublicRoute(req)) { return } - const authAwaited = await auth() + try { + // Get auth data + const authAwaited = await auth() - if (isProtectedRoute(req)) { - if (!authAwaited.userId) { + // Handle protected routes - redirect to sign-in if not authenticated + if (isProtectedRoute(req) && !authAwaited.userId) { return NextResponse.redirect(new URL('/sign-in', req.url)) } - } - if (isAdminRoute(req)) { - const userData = authAwaited.orgRole - if (userData !== 'admin') { + // Handle admin routes - redirect to home if not admin + if (isAdminRoute(req) && authAwaited.orgRole !== 'admin') { return NextResponse.redirect(new URL('/', req.url)) } - } - if (!authAwaited.orgId) { - return NextResponse.redirect(new URL('/onboarding', req.url)) - } - - // Synchronize user and organization data - try { - // Only perform sync for protected routes and non-webhook routes - if (isProtectedRoute(req) && !isWebhookRoute(req)) { - await ensureUserAndOrgSync(authAwaited.userId, authAwaited.orgId) + // Redirect to onboarding if no org is selected + if (isProtectedRoute(req) && !authAwaited.orgId) { + return NextResponse.redirect(new URL('/onboarding', req.url)) } - } catch (error) { - console.error('Sync error in middleware:', error) - // Continue with the request even if sync fails - // This prevents the application from being unusable if sync fails - } - const clerkClientInstance = await clerkClient() - const userMetadata = await clerkClientInstance.users.getUser( - authAwaited.userId - ) + // Only proceed with sync if we have both userId and orgId + if (isProtectedRoute(req) && authAwaited.userId && authAwaited.orgId) { + try { + await ensureUserAndOrgSync(authAwaited.userId, authAwaited.orgId) + } catch (syncError) { + console.error('Sync error in middleware:', syncError) + // Continue with the request even if sync fails + } - if ( - isProtectedRoute(req) && - !userMetadata?.publicMetadata?.hasCompletedOnboarding - ) { - return NextResponse.redirect(new URL('/onboarding', req.url)) - } + // Check onboarding status + try { + const clerkClientInstance = await clerkClient() + const userMetadata = await clerkClientInstance.users.getUser( + authAwaited.userId + ) + + if (!userMetadata?.publicMetadata?.hasCompletedOnboarding) { + return NextResponse.redirect(new URL('/onboarding', req.url)) + } + } catch (metadataError) { + console.error('Error checking user metadata:', metadataError) + // Allow the request to continue if metadata check fails + } - if (isProtectedRoute(req)) await auth.protect() + // Protect the route if all checks pass + await auth.protect() + } + } catch (error) { + console.error('Critical middleware error:', error) + // For critical errors, redirect to sign-in as a safe fallback + return NextResponse.redirect(new URL('/sign-in', req.url)) + } }) export const config = { From 7d5d43a0c6d3c37f87fb6ca58e7e1b8bbd8b9bba Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 11:36:25 +0200 Subject: [PATCH 45/73] feat(db): update pb schema with new fields - Change "ActivityLog" to "AppUser" and adjust ID - Add new fields: organization, user, equipment, metadata - Update existing field names and types for clarity - Include auto-generated date fields for created and updated timestamps --- .cursor/md/example-export-pb-schema.md | 236 ++++++++++++------------- 1 file changed, 110 insertions(+), 126 deletions(-) diff --git a/.cursor/md/example-export-pb-schema.md b/.cursor/md/example-export-pb-schema.md index 958eec9..1ff093b 100644 --- a/.cursor/md/example-export-pb-schema.md +++ b/.cursor/md/example-export-pb-schema.md @@ -1,14 +1,15 @@ -# pb schema +# pb schema + ```json [ { - "id": "pbc_879879449", + "id": "pbc_647898912", "listRule": null, "viewRule": null, "createRule": null, "updateRule": null, "deleteRule": null, - "name": "AppUser", + "name": "ActivityLog", "type": "base", "fields": [ { @@ -26,44 +27,109 @@ "type": "text" }, { - "autogeneratePattern": "", + "cascadeDelete": false, + "collectionId": "pbc_2387082370", "hidden": false, - "id": "text3885137012", - "max": 0, - "min": 0, - "name": "email", - "pattern": "", + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", "presentable": false, - "primaryKey": false, "required": false, "system": false, - "type": "text" + "type": "relation" }, { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", "hidden": false, - "id": "bool1547992806", - "name": "emailVisibility", + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "user", "presentable": false, "required": false, "system": false, - "type": "bool" + "type": "relation" }, { + "cascadeDelete": false, + "collectionId": "pbc_156890547", "hidden": false, - "id": "bool256245529", - "name": "verified", + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", "presentable": false, "required": false, "system": false, - "type": "bool" + "type": "relation" + }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_879879449", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "AppUser", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" }, { "autogeneratePattern": "", "hidden": false, - "id": "text1579384326", + "id": "text3885137012", "max": 0, "min": 0, - "name": "name", + "name": "email", "pattern": "", "presentable": false, "primaryKey": false, @@ -72,25 +138,30 @@ "type": "text" }, { - "cascadeDelete": false, - "collectionId": "pbc_3500197394", "hidden": false, - "id": "relation376926767", - "maxSelect": 1, - "minSelect": 0, - "name": "avatar", + "id": "bool1547992806", + "name": "emailVisibility", "presentable": false, "required": false, "system": false, - "type": "relation" + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": false, + "type": "bool" }, { "autogeneratePattern": "", "hidden": false, - "id": "text1146066909", + "id": "text1579384326", "max": 0, "min": 0, - "name": "phone", + "name": "name", "pattern": "", "presentable": false, "primaryKey": false, @@ -159,6 +230,16 @@ "system": false, "type": "relation" }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, { "hidden": false, "id": "autodate2990389176", @@ -316,103 +397,6 @@ "indexes": [], "system": false }, - { - "id": "pbc_647898912", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "ActivityLog", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", - "hidden": false, - "id": "relation2106360836", - "maxSelect": 1, - "minSelect": 0, - "name": "organization", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "hidden": false, - "id": "relation1689669068", - "maxSelect": 1, - "minSelect": 0, - "name": "user", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_156890547", - "hidden": false, - "id": "relation3433725209", - "maxSelect": 1, - "minSelect": 0, - "name": "equipment", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "json1326724116", - "maxSize": 0, - "name": "metadata", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, { "id": "pbc_156890547", "listRule": null, @@ -962,4 +946,4 @@ "system": false } ] -``` \ No newline at end of file +``` From bf325627bb79ffad322d8b0fedfc865d233686ae Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 13:33:47 +0200 Subject: [PATCH 46/73] feat(docs): update example export PB schema - Revise JSON structure for clarity - Ensure consistent field definitions across entities - Add missing fields and relations for completeness - Improve formatting for better readability --- .cursor/md/example-export-pb-schema.md | 1886 ++++++++--------- .../services/clerk-sync/cacheService.ts | 204 -- .../services/clerk-sync/onBoardingHelper.ts | 4 +- .../services/clerk-sync/reconciliation.ts | 13 +- .../services/clerk-sync/syncMiddleware.ts | 51 +- .../services/clerk-sync/syncService.ts | 416 +--- .../services/clerk-sync/webhook-handler.ts | 127 +- .../pocketbase/api_client/base_service.ts | 198 ++ .../services/pocketbase/api_client/client.ts | 121 ++ .../services/pocketbase/api_client/index.ts | 59 + .../services/pocketbase/api_client/schemas.ts | 154 ++ .../services/pocketbase/api_client/types.ts | 143 ++ .../services/pocketbase/app-user/auth.ts | 44 - .../services/pocketbase/app-user/core.ts | 206 -- .../services/pocketbase/app-user/index.ts | 34 - .../services/pocketbase/app-user/internal.ts | 124 -- .../services/pocketbase/app-user/search.ts | 148 -- .../pocketbase/app-user/webhook-handlers.ts | 173 -- .../services/pocketbase/app_user_service.ts | 183 ++ .../services/pocketbase/assignmentService.ts | 466 ---- .../services/pocketbase/baseService.ts | 195 -- .../services/pocketbase/equipmentService.ts | 363 ---- .../services/pocketbase/equipment_service.ts | 204 ++ src/app/actions/services/pocketbase/index.ts | 42 + .../services/pocketbase/organization/core.ts | 346 --- .../services/pocketbase/organization/index.ts | 36 - .../pocketbase/organization/internal.ts | 134 -- .../pocketbase/organization/membership.ts | 188 -- .../pocketbase/organization/security.ts | 39 - .../organization/webhook-handlers.ts | 318 --- .../pocketbase/organization_service.ts | 134 ++ .../services/pocketbase/projectService.ts | 306 --- .../pocketbase/secured/equipment_service.ts | 211 ++ .../services/pocketbase/secured/index.ts | 14 + .../pocketbase/secured/security_middleware.ts | 147 ++ .../services/pocketbase/securityUtils.ts | 6 +- .../clerk/organization-membership/route.ts | 113 +- .../api/webhook/clerk/organization/route.ts | 165 +- src/app/api/webhook/clerk/user/route.ts | 51 +- src/types/types_pocketbase.ts | 147 -- src/types/webhooks.ts | 64 - 41 files changed, 2872 insertions(+), 5105 deletions(-) delete mode 100644 src/app/actions/services/clerk-sync/cacheService.ts create mode 100644 src/app/actions/services/pocketbase/api_client/base_service.ts create mode 100644 src/app/actions/services/pocketbase/api_client/client.ts create mode 100644 src/app/actions/services/pocketbase/api_client/index.ts create mode 100644 src/app/actions/services/pocketbase/api_client/schemas.ts create mode 100644 src/app/actions/services/pocketbase/api_client/types.ts delete mode 100644 src/app/actions/services/pocketbase/app-user/auth.ts delete mode 100644 src/app/actions/services/pocketbase/app-user/core.ts delete mode 100644 src/app/actions/services/pocketbase/app-user/index.ts delete mode 100644 src/app/actions/services/pocketbase/app-user/internal.ts delete mode 100644 src/app/actions/services/pocketbase/app-user/search.ts delete mode 100644 src/app/actions/services/pocketbase/app-user/webhook-handlers.ts create mode 100644 src/app/actions/services/pocketbase/app_user_service.ts delete mode 100644 src/app/actions/services/pocketbase/assignmentService.ts delete mode 100644 src/app/actions/services/pocketbase/baseService.ts delete mode 100644 src/app/actions/services/pocketbase/equipmentService.ts create mode 100644 src/app/actions/services/pocketbase/equipment_service.ts create mode 100644 src/app/actions/services/pocketbase/index.ts delete mode 100644 src/app/actions/services/pocketbase/organization/core.ts delete mode 100644 src/app/actions/services/pocketbase/organization/index.ts delete mode 100644 src/app/actions/services/pocketbase/organization/internal.ts delete mode 100644 src/app/actions/services/pocketbase/organization/membership.ts delete mode 100644 src/app/actions/services/pocketbase/organization/security.ts delete mode 100644 src/app/actions/services/pocketbase/organization/webhook-handlers.ts create mode 100644 src/app/actions/services/pocketbase/organization_service.ts delete mode 100644 src/app/actions/services/pocketbase/projectService.ts create mode 100644 src/app/actions/services/pocketbase/secured/equipment_service.ts create mode 100644 src/app/actions/services/pocketbase/secured/index.ts create mode 100644 src/app/actions/services/pocketbase/secured/security_middleware.ts delete mode 100644 src/types/types_pocketbase.ts delete mode 100644 src/types/webhooks.ts diff --git a/.cursor/md/example-export-pb-schema.md b/.cursor/md/example-export-pb-schema.md index 1ff093b..eca3d55 100644 --- a/.cursor/md/example-export-pb-schema.md +++ b/.cursor/md/example-export-pb-schema.md @@ -2,948 +2,948 @@ ```json [ - { - "id": "pbc_647898912", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "ActivityLog", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", - "hidden": false, - "id": "relation2106360836", - "maxSelect": 1, - "minSelect": 0, - "name": "organization", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "hidden": false, - "id": "relation1689669068", - "maxSelect": 1, - "minSelect": 0, - "name": "user", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_156890547", - "hidden": false, - "id": "relation3433725209", - "maxSelect": 1, - "minSelect": 0, - "name": "equipment", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "json1326724116", - "maxSize": 0, - "name": "metadata", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_879879449", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "AppUser", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text3885137012", - "max": 0, - "min": 0, - "name": "email", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "bool1547992806", - "name": "emailVisibility", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }, - { - "hidden": false, - "id": "bool256245529", - "name": "verified", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1579384326", - "max": 0, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1466534506", - "max": 0, - "min": 0, - "name": "role", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "bool2165931080", - "name": "isAdmin", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }, - { - "hidden": false, - "id": "date2697416787", - "max": "", - "min": "", - "name": "lastLogin", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text3875972033", - "max": 0, - "min": 0, - "name": "clerkId", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", - "hidden": false, - "id": "relation1115430015", - "maxSelect": 1, - "minSelect": 0, - "name": "organizations", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "json1326724116", - "maxSize": 0, - "name": "metadata", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_2166913018", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "Assignment", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", - "hidden": false, - "id": "relation2106360836", - "maxSelect": 1, - "minSelect": 0, - "name": "organization", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_156890547", - "hidden": false, - "id": "relation3433725209", - "maxSelect": 1, - "minSelect": 0, - "name": "equipment", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "hidden": false, - "id": "relation1706602226", - "maxSelect": 1, - "minSelect": 0, - "name": "assignedToUser", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1901958808", - "hidden": false, - "id": "relation3498911044", - "maxSelect": 1, - "minSelect": 0, - "name": "assignedToProject", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "date1269603864", - "max": "", - "min": "", - "name": "startDate", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "hidden": false, - "id": "date826688707", - "max": "", - "min": "", - "name": "endDate", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "convertURLs": false, - "hidden": false, - "id": "editor18589324", - "maxSize": 0, - "name": "notes", - "presentable": false, - "required": false, - "system": false, - "type": "editor" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_156890547", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "Equipement", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", - "hidden": false, - "id": "relation2106360836", - "maxSelect": 1, - "minSelect": 0, - "name": "organization", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1579384326", - "max": 0, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text231537297", - "max": 0, - "min": 0, - "name": "qrNfcCode", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1874629670", - "max": 0, - "min": 0, - "name": "tags", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "convertURLs": false, - "hidden": false, - "id": "editor18589324", - "maxSize": 0, - "name": "notes", - "presentable": false, - "required": false, - "system": false, - "type": "editor" - }, - { - "hidden": false, - "id": "date58351749", - "max": "", - "min": "", - "name": "acquisitionDate", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_156890547", - "hidden": false, - "id": "relation1849591526", - "maxSelect": 1, - "minSelect": 0, - "name": "parentEquipment", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_3500197394", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "Images", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text724990059", - "max": 0, - "min": 0, - "name": "title", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text678750603", - "max": 0, - "min": 0, - "name": "alt", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text4135340389", - "max": 0, - "min": 0, - "name": "caption", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "file3309110367", - "maxSelect": 1, - "maxSize": 0, - "mimeTypes": [], - "name": "image", - "presentable": false, - "protected": false, - "required": false, - "system": false, - "thumbs": [], - "type": "file" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_2387082370", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "Organization", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1579384326", - "max": 0, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text3885137012", - "max": 0, - "min": 0, - "name": "email", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1146066909", - "max": 0, - "min": 0, - "name": "phone", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text223244161", - "max": 0, - "min": 0, - "name": "address", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "json3846545605", - "maxSize": 0, - "name": "settings", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text3875972033", - "max": 0, - "min": 0, - "name": "clerkId", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1278432162", - "max": 0, - "min": 0, - "name": "stripeCustomerId", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text3396850601", - "max": 0, - "min": 0, - "name": "subscriptionId", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text212635077", - "max": 0, - "min": 0, - "name": "subscriptionStatus", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text2938615432", - "max": 0, - "min": 0, - "name": "priceId", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_1901958808", - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "Project", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1579384326", - "max": 0, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text223244161", - "max": 0, - "min": 0, - "name": "address", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "convertURLs": false, - "hidden": false, - "id": "editor18589324", - "maxSize": 0, - "name": "notes", - "presentable": false, - "required": false, - "system": false, - "type": "editor" - }, - { - "hidden": false, - "id": "date1269603864", - "max": "", - "min": "", - "name": "startDate", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "hidden": false, - "id": "date826688707", - "max": "", - "min": "", - "name": "endDate", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", - "hidden": false, - "id": "relation2106360836", - "maxSelect": 1, - "minSelect": 0, - "name": "organization", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - } + { + "id": "pbc_647898912", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "ActivityLog", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_879879449", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "AppUser", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3885137012", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1466534506", + "max": 0, + "min": 0, + "name": "role", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool2165931080", + "name": "isAdmin", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "date2697416787", + "max": "", + "min": "", + "name": "lastLogin", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3875972033", + "max": 0, + "min": 0, + "name": "clerkId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation1115430015", + "maxSelect": 1, + "minSelect": 0, + "name": "organizations", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2166913018", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Assignment", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation1706602226", + "maxSelect": 1, + "minSelect": 0, + "name": "assignedToUser", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1901958808", + "hidden": false, + "id": "relation3498911044", + "maxSelect": 1, + "minSelect": 0, + "name": "assignedToProject", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "date1269603864", + "max": "", + "min": "", + "name": "startDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date826688707", + "max": "", + "min": "", + "name": "endDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_156890547", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Equipment", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text231537297", + "max": 0, + "min": 0, + "name": "qrNfcCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1874629670", + "max": 0, + "min": 0, + "name": "tags", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "date58351749", + "max": "", + "min": "", + "name": "acquisitionDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation1849591526", + "maxSelect": 1, + "minSelect": 0, + "name": "parentEquipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3500197394", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Images", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text678750603", + "max": 0, + "min": 0, + "name": "alt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4135340389", + "max": 0, + "min": 0, + "name": "caption", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "file3309110367", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "image", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2387082370", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Organization", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3885137012", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text223244161", + "max": 0, + "min": 0, + "name": "address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json3846545605", + "maxSize": 0, + "name": "settings", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3875972033", + "max": 0, + "min": 0, + "name": "clerkId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1278432162", + "max": 0, + "min": 0, + "name": "stripeCustomerId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3396850601", + "max": 0, + "min": 0, + "name": "subscriptionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text212635077", + "max": 0, + "min": 0, + "name": "subscriptionStatus", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2938615432", + "max": 0, + "min": 0, + "name": "priceId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1901958808", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Project", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text223244161", + "max": 0, + "min": 0, + "name": "address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "date1269603864", + "max": "", + "min": "", + "name": "startDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date826688707", + "max": "", + "min": "", + "name": "endDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + } ] ``` diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts deleted file mode 100644 index c020921..0000000 --- a/src/app/actions/services/clerk-sync/cacheService.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { createHash as nodeCreateHash } from 'crypto' - -/** - * Type for cacheable data to avoid using any - */ -type CacheableValue = unknown - -/** - * Interface for cache entries with security features - */ -interface SecureCacheEntry { - data: T - timestamp: number - userId: string - hash: string -} - -/** - * Secure caching service with user-specific isolation and integrity validation - * Implements security best practices to prevent cache manipulation attacks - */ -class SecureCache { - private readonly cache: Map> - private readonly secretKey: string - - constructor() { - this.cache = new Map() - - // Use the environment secret or generate a random one per instance - // This makes cache manipulation attacks significantly harder - this.secretKey = - process.env.CACHE_SECRET || - Array.from({ length: 32 }, () => - Math.floor(Math.random() * 256) - .toString(16) - .padStart(2, '0') - ).join('') - } - - /** - * Creates a cryptographic hash to verify data integrity - * Compatible with Edge runtime - * - * @param data The data to hash - * @param userId The user ID to include in the hash - * @returns The generated hash - */ - private createHash(data: CacheableValue, userId: string): string { - const content = JSON.stringify(data) + userId + this.secretKey - - // Use Web Crypto API which is available in Edge runtime - if (typeof crypto !== 'undefined' && crypto.subtle) { - // Convert string to ArrayBuffer - const encoder = new TextEncoder() - const dataBuffer = encoder.encode(content) - - // Create a promise that will be resolved synchronously - let hashHex = '' - // Use subtle.digest which returns a Promise - try { - crypto.subtle.digest('SHA-256', dataBuffer).then(hashBuffer => { - // Convert buffer to byte array - const hashArray = Array.from(new Uint8Array(hashBuffer)) - // Convert bytes to hex string - hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - }) - } catch (error) { - console.error('Crypto digest failed:', error) - } - - if (hashHex) return hashHex - return this.simpleHash(content) - } - - return this.simpleHash(content) - } - - /** - * Simple hash function as fallback - * Not as secure as crypto but works in all environments - */ - private simpleHash(str: string): string { - let hash = 0 - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i) - hash = (hash << 5) - hash + char - hash = hash & hash // Convert to 32bit integer - } - // Convert to hex string and ensure it's 64 chars long for consistency - return Math.abs(hash).toString(16).padStart(64, '0') - } - - /** - * Stores a value in the cache with security validation - * - * @param key The cache key - * @param value The value to store - * @param userId The user ID for isolation and validation - * @param ttl Optional TTL in milliseconds (defaults to 2 minutes) - */ - set( - key: string, - value: CacheableValue, - userId: string, - ttl: number = 2 * 60 * 1000 - ): void { - // Create an integrity hash that binds the data to this specific user - const integrityHash = this.createHash(value, userId) - - this.cache.set(key, { - data: value, - hash: integrityHash, - timestamp: Date.now(), - userId, - }) - - // Set automatic expiration for this entry - if (ttl > 0) { - setTimeout(() => { - this.cache.delete(key) - }, ttl) - } - } - - /** - * Retrieves a value from the cache with security checks - * - * @param key The cache key - * @param userId The user ID for isolation and validation - * @param maxAge Optional maximum age in milliseconds - * @returns The cached value or null if not found/invalid - */ - get( - key: string, - userId: string, - maxAge: number = 2 * 60 * 1000 - ): CacheableValue | null { - const entry = this.cache.get(key) - - // No entry found - if (!entry) { - return null - } - - // Check if entry has expired - if (maxAge > 0 && Date.now() - entry.timestamp > maxAge) { - this.cache.delete(key) - return null - } - - // Enforce user isolation - users can only access their own cache entries - if (entry.userId !== userId) { - return null - } - - // Verify data integrity using the hash - const expectedHash = this.createHash(entry.data, userId) - if (entry.hash !== expectedHash) { - // Hash mismatch indicates potential tampering - remove the entry - this.cache.delete(key) - console.warn(`Cache integrity violation detected for key: ${key}`) - return null - } - - return entry.data - } - - /** - * Invalidates a cache entry - * - * @param key The cache key to invalidate - * @param userId Optional user ID for additional security - */ - invalidate(key: string, userId?: string): void { - // If userId is provided, only allow invalidation of that user's entries - if (userId) { - const entry = this.cache.get(key) - if (entry && entry.userId === userId) { - this.cache.delete(key) - } - } else { - this.cache.delete(key) - } - } - - /** - * Clears all cache entries - use with caution - */ - clear(): void { - this.cache.clear() - } - - /** - * Gets the current size of the cache - * - * @returns Number of entries in the cache - */ - size(): number { - return this.cache.size - } -} - -// Singleton instance for app-wide use -export const secureCache = new SecureCache() diff --git a/src/app/actions/services/clerk-sync/onBoardingHelper.ts b/src/app/actions/services/clerk-sync/onBoardingHelper.ts index 412bb0a..e346069 100644 --- a/src/app/actions/services/clerk-sync/onBoardingHelper.ts +++ b/src/app/actions/services/clerk-sync/onBoardingHelper.ts @@ -1,7 +1,7 @@ import { syncUserToPocketBase, syncOrganizationToPocketBase, - linkUserToOrganization, + linkUserToOrganizationFromClerk, } from '@/app/actions/services/clerk-sync/syncService' import { auth, clerkClient } from '@clerk/nextjs/server' @@ -50,7 +50,7 @@ export async function importOrganizationAfterCreation(clerkOrgId: string) { } // Link the user to the organization - await linkUserToOrganization(membershipData) + await linkUserToOrganizationFromClerk(membershipData) return { organization, diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts index 67c10e2..cc3a5f4 100644 --- a/src/app/actions/services/clerk-sync/reconciliation.ts +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -5,7 +5,7 @@ import type { Organization, User } from '@clerk/nextjs/server' import { syncUserToPocketBase, syncOrganizationToPocketBase, - linkUserToOrganization, + linkUserToOrganizationFromClerk, } from '@/app/actions/services/clerk-sync/syncService' // src/app/actions/services/clerk-sync/reconciliation.ts import { clerkClient } from '@clerk/nextjs/server' @@ -84,13 +84,16 @@ export async function runFullReconciliation() { for (const membership of memberships) { try { + // Skip if userId is undefined + if (!membership.publicUserData?.userId) continue + const membershipData = { organization: { id: org.id }, - public_user_data: { user_id: membership.publicUserData?.userId }, + public_user_data: { user_id: membership.publicUserData.userId }, role: membership.role, } - await linkUserToOrganization(membershipData) + await linkUserToOrganizationFromClerk(membershipData) membershipCount++ } catch (error) { if (membership.publicUserData) { @@ -180,7 +183,7 @@ export async function reconcileSpecificUser(clerkUserId: string) { role: membership.role, } - await linkUserToOrganization(membershipData) + await linkUserToOrganizationFromClerk(membershipData) } return { @@ -242,7 +245,7 @@ export async function reconcileSpecificOrganization(clerkOrgId: string) { role: membership.role, } - await linkUserToOrganization(membershipData) + await linkUserToOrganizationFromClerk(membershipData) } return { diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts index 1cbbdd4..91ba76d 100644 --- a/src/app/actions/services/clerk-sync/syncMiddleware.ts +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -1,13 +1,14 @@ 'use server' -import { secureCache } from '@/app/actions/services/clerk-sync/cacheService' +import { auth, clerkClient } from '@clerk/nextjs/server' +import { NextResponse } from 'next/server' + import { syncUserToPocketBase, syncOrganizationToPocketBase, - linkUserToOrganization, -} from '@/app/actions/services/clerk-sync/syncService' -import { auth, clerkClient } from '@clerk/nextjs/server' -import { NextResponse } from 'next/server' + linkUserToOrganizationFromClerk, + ClerkMembershipData, +} from './syncService' /** * Type for any data accepted by server actions @@ -21,10 +22,6 @@ type ActionData = Record * @returns The modified response */ export async function syncMiddleware() { - // we will probably need to add : - // todo: add a check to see if the request is for the webhook - // * @param request The incoming request -> to be able to check if the request is for the webhook - // Only run this middleware for authenticated routes const { orgId, userId } = await auth() @@ -34,25 +31,8 @@ export async function syncMiddleware() { } try { - const cacheKey = `sync:${userId}:${orgId || 'none'}` - - // Check if we've recently synced this user to avoid excessive checks - // This is critical for performance in high-traffic scenarios - const cachedSync = secureCache.get(cacheKey, userId) - - if (!cachedSync) { - // If no cached result, perform the sync - await ensureUserAndOrgSync(userId, orgId) - - // Cache the result to avoid frequent syncs - // TTL of 5 minutes is a good balance between security and performance - secureCache.set( - cacheKey, - { synced: true, timestamp: Date.now() }, - userId, - 5 * 60 * 1000 - ) - } + // Perform the sync without using cache + await ensureUserAndOrgSync(userId, orgId) } catch (error) { // Log error but don't block the request // This ensures the app remains functional even if sync fails @@ -75,41 +55,40 @@ export async function ensureUserAndOrgSync( clerkOrgId?: string | null ) { // 1. First, try to get fresh data from Clerk - const clerkClientInstance = await clerkClient() - const clerkUser = await clerkClientInstance.users.getUser(clerkUserId) + const clerkAPI = await clerkClient() + const clerkUser = await clerkAPI.users.getUser(clerkUserId) // 2. Sync the user to PocketBase await syncUserToPocketBase(clerkUser) // 3. If an organization ID is provided, sync that too if (clerkOrgId) { - const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + const clerkOrg = await clerkAPI.organizations.getOrganization({ organizationId: clerkOrgId, }) - // todo : fix types await syncOrganizationToPocketBase(clerkOrg) // 4. Ensure the user-organization relationship exists - // This uses data available in the membership EndpointSecretOut const memberships = - await clerkClientInstance.organizations.getOrganizationMembershipList({ + await clerkAPI.organizations.getOrganizationMembershipList({ organizationId: clerkOrgId, }) + // Trouver le membership pour cet utilisateur const membership = memberships.data.find( m => m.publicUserData?.userId === clerkUserId ) if (membership) { // Prepare membership data in the format expected by linkUserToOrganization - const membershipData = { + const membershipData: ClerkMembershipData = { organization: { id: clerkOrgId }, public_user_data: { user_id: clerkUserId }, role: membership.role, } - await linkUserToOrganization(membershipData) + await linkUserToOrganizationFromClerk(membershipData) } } diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index df7e7f6..7cca999 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -1,67 +1,34 @@ +'use server' + +import type { + User, + Organization as ClerkOrganization, +} from '@clerk/nextjs/server' + +import { AppUser, Organization } from '../pocketbase/api_client/types' import { - getByClerkId, - _createAppUser, - _updateAppUser, -} from '@/app/actions/services/pocketbase/app-user/internal' -// src/app/actions/services/clerk-sync/syncService.ts -import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' -import { - getOrganizationByClerkId, - _createOrganization, - _updateOrganization, -} from '@/app/actions/services/pocketbase/organization/internal' + getAppUserService, + AppUserCreateInput, + createOrUpdateUserByClerkId, +} from '../pocketbase/app_user_service' import { - AppUser, - Organization as PBOrganization, -} from '@/types/types_pocketbase' -import { clerkClient, User } from '@clerk/nextjs/server' + getOrganizationService, + OrganizationCreateInput, + createOrUpdateOrganizationByClerkId, +} from '../pocketbase/organization_service' /** - * Type definitions for Clerk user data + * Type interface for Clerk Organization with additional fields */ -type ClerkUserData = { - id: string - first_name?: string - last_name?: string - username?: string - image_url?: string - email_addresses?: Array<{ - email_address: string - verification?: { - status?: string - } - }> - phone_numbers?: Array<{ - phone_number: string - }> - public_metadata?: { - isAdmin?: boolean - role?: string - [key: string]: unknown - } - [key: string]: unknown +interface EnhancedClerkOrganization extends ClerkOrganization { + emailAddress?: string + phoneNumber?: string } /** - * Type definitions for Clerk organization data + * Type for Clerk organization membership data */ -type ClerkOrganizationData = { - id: string - name?: string - email_address?: string - phone_number?: string - public_metadata?: { - address?: string - settings?: Record - [key: string]: unknown - } - [key: string]: unknown -} - -/** - * Type definitions for Clerk membership data - */ -type ClerkMembershipData = { +export interface ClerkMembershipData { organization: { id: string } @@ -69,94 +36,77 @@ type ClerkMembershipData = { user_id: string } role?: string - [key: string]: unknown } /** - * Synchronizes Clerk user data to PocketBase - * @param user The user data from Clerk webhook or API + * Synchronizes a Clerk user to PocketBase + * @param clerkUser - The user data from Clerk webhook or API * @returns The created or updated user */ -export async function syncUserToPocketBase(user: User): Promise { +export async function syncUserToPocketBase(clerkUser: User): Promise { try { - const { - createdAt, - emailAddresses, - firstName, - id: clerkId, - lastName, - lastSignInAt, - phoneNumbers, - primaryPhoneNumberId, - publicMetadata, - updatedAt, - } = user - - console.info('Syncing user with clerkId:', clerkId) - console.info('User data:', JSON.stringify(user, null, 2)) + console.info('Syncing user with clerkId:', clerkUser.id) - if (!clerkId) { + if (!clerkUser.id) { throw new Error('Clerk user ID is required for syncing') } // Get primary email - const primaryEmail = emailAddresses.find( - email => email.id === user.primaryEmailAddressId + const primaryEmail = clerkUser.emailAddresses.find( + email => email.id === clerkUser.primaryEmailAddressId ) + if (!primaryEmail) { throw new Error('User must have a primary email address') } - // Get primary phone if available - const primaryPhone = phoneNumbers.find( - phone => phone.id === primaryPhoneNumberId - ) - - // Try to find existing AppUser - const existingUser = await getByClerkId(clerkId) - - // Prepare user data with all available fields - const userDataToSync: Partial = { - clerkId, + // Map Clerk data to our format + const userData = { email: primaryEmail.emailAddress, emailVisibility: true, - // Set admin status based on metadata - isAdmin: publicMetadata?.isAdmin === true, - // Convert Clerk's lastSignInAt to a format PocketBase understands - lastLogin: lastSignInAt ? new Date(lastSignInAt).toISOString() : '', - // Now correctly typed thanks to the interface update + isAdmin: clerkUser.publicMetadata?.isAdmin === true, + lastLogin: clerkUser.lastSignInAt + ? new Date(clerkUser.lastSignInAt).toISOString() + : '', metadata: { - createdAt: createdAt, + createdAt: clerkUser.createdAt, externalAccounts: - user.externalAccounts?.map(account => ({ - email: account.emailAddress, - imageUrl: account.imageUrl, + clerkUser.externalAccounts?.map(account => ({ + email: account.emailAddress || '', + imageUrl: account.imageUrl || '', provider: account.provider, providerUserId: account.externalId, - })) ?? [], // Use ?? instead of || for better null handling - hasCompletedOnboarding: publicMetadata?.hasCompletedOnboarding === true, - lastActiveAt: user.lastActiveAt, - onboardingCompletedAt: publicMetadata?.onboardingCompletedAt, - public: publicMetadata ?? {}, // Use ?? instead of || - updatedAt: updatedAt, + })) || [], + hasCompletedOnboarding: + clerkUser.publicMetadata?.hasCompletedOnboarding === true, + lastActiveAt: clerkUser.lastActiveAt, + onboardingCompletedAt: clerkUser.publicMetadata + ?.onboardingCompletedAt as string, + public: { + hasCompletedOnboarding: + clerkUser.publicMetadata?.hasCompletedOnboarding === true, + onboardingCompletedAt: clerkUser.publicMetadata + ?.onboardingCompletedAt as string, + }, + updatedAt: clerkUser.updatedAt, }, name: - firstName && lastName - ? `${firstName} ${lastName}` - : (user.username ?? 'Unknown'), - phone: primaryPhone?.phoneNumber ?? '', // Use ?? instead of || - role: typeof publicMetadata?.role === 'string' ? publicMetadata.role : '', + `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim() || + clerkUser.username || + 'Unknown', + organizations: '', + role: + typeof clerkUser.publicMetadata?.role === 'string' + ? (clerkUser.publicMetadata.role as string) + : 'member', verified: primaryEmail.verification?.status === 'verified', } - // Update or create - if (existingUser) { - console.info(`Updating existing AppUser ${clerkId} in PocketBase`) - return await _updateAppUser(existingUser.id, userDataToSync) - } else { - console.info(`Creating new AppUser ${clerkId} in PocketBase`) - return await _createAppUser(userDataToSync) - } + // Use the utility function to create/update the user + return await createOrUpdateUserByClerkId( + clerkUser.id, + userData as Omit + ) } catch (error) { console.error('Error syncing user to PocketBase:', error) throw error @@ -164,65 +114,49 @@ export async function syncUserToPocketBase(user: User): Promise { } /** - * Synchronizes Clerk organization data to PocketBase - * @param organization The organization data from Clerk webhook or API + * Synchronizes a Clerk organization to PocketBase + * @param organization - The organization data from Clerk * @returns The created or updated organization */ export async function syncOrganizationToPocketBase( - organization: ClerkOrganizationData -): Promise { + organization: EnhancedClerkOrganization +): Promise { try { - const { - created_at, - email_address, - id: clerkId, - name, - phone_number, - public_metadata, - updated_at, - } = organization - - if (!clerkId) { + if (!organization.id) { throw new Error('Clerk organization ID is required for syncing') } - console.info(`Attempting to sync organization with clerkId: ${clerkId}`) - - // Try to find existing Organization - const existingOrg = await getOrganizationByClerkId(clerkId) - console.info('Existing org lookup result:', existingOrg) + console.info( + `Attempting to sync organization with clerkId: ${organization.id}` + ) - // Prepare organization data with all available fields - const orgDataToSync: Partial = { - address: public_metadata?.address ?? '', - clerkId, - email: email_address ?? '', - name: name ?? 'Unnamed Organization', - phone: phone_number ?? '', - // Fix type issues - convert to string instead of empty object - priceId: (public_metadata?.priceId as string) ?? '', // Type correction + // Map Clerk data to our format + const orgData = { + address: (organization.publicMetadata?.address as string) || '', + email: organization.emailAddress || '', + name: organization.name || 'Unnamed Organization', + phone: organization.phoneNumber || '', + priceId: (organization.publicMetadata?.priceId as string) || '', settings: { - ...(public_metadata?.settings ?? {}), clerkData: { - createdAt: created_at, - updatedAt: updated_at, - ...(public_metadata ?? {}), + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + ...organization.publicMetadata, }, }, - // Fix type issues - convert to string instead of empty object - stripeCustomerId: (public_metadata?.stripeCustomerId as string) ?? '', // Type correction - subscriptionId: (public_metadata?.subscriptionId as string) ?? '', // Type correction - subscriptionStatus: (public_metadata?.subscriptionStatus as string) ?? '', // Type correction + stripeCustomerId: + (organization.publicMetadata?.stripeCustomerId as string) || '', + subscriptionId: + (organization.publicMetadata?.subscriptionId as string) || '', + subscriptionStatus: + (organization.publicMetadata?.subscriptionStatus as string) || '', } - // Update or create - if (existingOrg) { - console.info(`Updating existing Organization ${clerkId} in PocketBase`) - return await _updateOrganization(existingOrg.id, orgDataToSync) - } else { - console.info(`Creating new Organization ${clerkId} in PocketBase`) - return await _createOrganization(orgDataToSync) - } + // Use the utility function to create/update the organization + return await createOrUpdateOrganizationByClerkId( + organization.id, + orgData as Omit + ) } catch (error) { console.error('Error syncing organization to PocketBase:', error) throw error @@ -230,165 +164,47 @@ export async function syncOrganizationToPocketBase( } /** - * Links a user to an organization in PocketBase based on Clerk membership data - * @param membershipData The membership data from Clerk webhook + * Links a user to an organization based on Clerk membership data + * @param membershipData - The membership data from Clerk * @returns Success status */ -export async function linkUserToOrganization( +export async function linkUserToOrganizationFromClerk( membershipData: ClerkMembershipData -): Promise { +): Promise { try { const userId = membershipData.public_user_data?.user_id const orgId = membershipData.organization.id - const role = membershipData.role // e.g., 'org:admin', 'org:member' - - // Extract just the role part (remove 'org:' prefix if present) - const normalizedRole = role?.replace('org:', '') ?? 'member' + const role = membershipData.role?.replace('org:', '') || 'member' if (!userId || !orgId) { throw new Error('Missing required IDs in membership data') } - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - console.info( - `Linking user ${userId} to organization ${orgId} with role ${normalizedRole}` + `Linking user ${userId} to organization ${orgId} with role ${role}` ) - // Find the user in PocketBase by Clerk ID - const pbUser = await pb - .collection('AppUser') - .getFirstListItem(`clerkId="${userId}"`) + // Get the services + const appUserService = getAppUserService() + const organizationService = getOrganizationService() + + // Find the PocketBase records + const user = await appUserService.findByClerkId(userId) + const org = await organizationService.findByClerkId(orgId) - // Find the organization in PocketBase by Clerk ID - const pbOrg = await pb - .collection('Organization') - .getFirstListItem(`clerkId="${orgId}"`) + if (!user || !org) { + console.error('User or organization not found in PocketBase') + return null + } console.info( - `Found user ${pbUser.id} and organization ${pbOrg.id} in PocketBase` + `Found user ${user.id} and organization ${org.id} in PocketBase` ) - // Update the user with the organization relation - await pb.collection('AppUser').update(pbUser.id, { - organizations: pbOrg.id, - }) - console.info(`Updated user ${pbUser.id} with organization ${pbOrg.id}`) - - // Update user role based on organization membership - if (normalizedRole === 'admin') { - await pb.collection('AppUser').update(pbUser.id, { - isAdmin: true, - role: 'admin', - }) - console.info(`Updated user ${pbUser.id} to admin role`) - } else { - // Update the role even if not admin - await pb.collection('AppUser').update(pbUser.id, { - role: normalizedRole, - }) - console.info(`Updated user ${pbUser.id} to ${normalizedRole} role`) - } - - return true + // Link the user to the organization + return await appUserService.linkToOrganization(user.id, org.id, role) } catch (error) { console.error('Error linking user to organization:', error) - throw error - } -} - -/** - * Fetch user data from Clerk by ID - * @param clerkId The Clerk user ID - * @returns The user data from Clerk - */ -export async function getClerkUserById( - clerkId: string -): Promise { - try { - const clerkClientInstance = await clerkClient() - const user = await clerkClientInstance.users.getUser(clerkId) - return { - id: user.id, - image_url: user.imageUrl, - } - } catch (error) { - console.error('Error fetching user from Clerk:', error) - throw error - } -} - -/** - * Fetch organization data from Clerk by ID - * @param clerkId The Clerk organization ID - * @returns The organization data from Clerk - */ -export async function getClerkOrganizationById( - clerkId: string -): Promise { - try { - const clerkClientInstance = await clerkClient() - const organization = - await clerkClientInstance.organizations.getOrganization({ - organizationId: clerkId, - }) - return { - id: organization.id, - name: organization.name, - } - } catch (error) { - console.error('Error fetching organization from Clerk:', error) - throw error - } -} - -/** - * Synchronizes user profile image from Clerk to PocketBase - * @param userId The PocketBase user ID - * @param imageUrl The Clerk image URL - * @returns Success status - */ -export async function syncUserProfileImage( - userId: string, - imageUrl: string -): Promise { - if (!imageUrl) return false - - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fetch the image data - const response = await fetch(imageUrl) - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`) - } - - // Convert to blob - const imageBlob = await response.blob() - - // Create a file object from the blob - const formData = new FormData() - formData.append('image', imageBlob, 'profile.jpg') - formData.append('title', 'Profile Photo') - formData.append('alt', 'User profile photo') - - // Create the image record in PocketBase - const imageRecord = await pb.collection('Images').create(formData) - - // Update the user with the image relation - await pb.collection('AppUser').update(userId, { - avatar: imageRecord.id, - }) - - return true - } catch (error) { - console.error('Error syncing user profile image:', error) - return false + return null } } diff --git a/src/app/actions/services/clerk-sync/webhook-handler.ts b/src/app/actions/services/clerk-sync/webhook-handler.ts index a60a9af..e850c0b 100644 --- a/src/app/actions/services/clerk-sync/webhook-handler.ts +++ b/src/app/actions/services/clerk-sync/webhook-handler.ts @@ -1,11 +1,20 @@ -import * as userService from '@/app/actions/services/pocketbase/app-user/webhook-handlers' -import * as organizationService from '@/app/actions/services/pocketbase/organization/webhook-handlers' -import { WebhookProcessingResult } from '@/types/webhooks' +'use server' + +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganizationFromClerk, + ClerkMembershipData, +} from '@/app/actions/services/clerk-sync/syncService' +import { WebhookEvent, clerkClient } from '@clerk/nextjs/server' + /** - * Central handler for processing Clerk webhook events - * Routes to appropriate service methods based on event type + * Result of processing a webhook event */ -import { WebhookEvent } from '@clerk/nextjs/server' +interface WebhookProcessingResult { + message: string + success: boolean +} /** * Process a Clerk webhook event @@ -18,52 +27,86 @@ export async function processWebhookEvent( console.info(`Processing webhook event: ${event.type}`) try { - // Temporarily elevate permissions for webhook processing - const elevated = true - // Handle organization events - if (event.type === 'organization.created') { - return await organizationService.handleWebhookCreated( - event.data, - elevated - ) - } else if (event.type === 'organization.updated') { - return await organizationService.handleWebhookUpdated( - event.data, - elevated - ) + if ( + event.type === 'organization.created' || + event.type === 'organization.updated' + ) { + // Récupérer l'organisation complète depuis Clerk + const clerkAPI = await clerkClient() + const organizationId = event.data.id as string + const completeOrg = await clerkAPI.organizations.getOrganization({ + organizationId, + }) + + const organization = await syncOrganizationToPocketBase(completeOrg) + return { + message: `Organization ${organization.name} (${organization.id}) synchronized successfully`, + success: true, + } } else if (event.type === 'organization.deleted') { - return await organizationService.handleWebhookDeleted( - event.data, - elevated - ) + // Pour l'instant, nous ne supprimons pas réellement les organisations + // C'est un choix de conception pour garder l'historique + return { + message: `Organization deletion registered but not processed (data preserved)`, + success: true, + } } // Handle membership events - else if (event.type === 'organizationMembership.created') { - return await organizationService.handleMembershipWebhookCreated( - event.data, - elevated - ) - } else if (event.type === 'organizationMembership.updated') { - return await organizationService.handleMembershipWebhookUpdated( - event.data, - elevated - ) + else if ( + event.type === 'organizationMembership.created' || + event.type === 'organizationMembership.updated' + ) { + // Convertir les données de membership dans notre format attendu + const membershipData: ClerkMembershipData = { + organization: { id: event.data.organization.id }, + public_user_data: { + user_id: event.data.public_user_data?.user_id, + }, + role: event.data.role, + } + + const user = await linkUserToOrganizationFromClerk(membershipData) + if (user) { + return { + message: `User ${user.name} linked to organization successfully`, + success: true, + } + } else { + return { + message: `Failed to link user to organization`, + success: false, + } + } } else if (event.type === 'organizationMembership.deleted') { - return await organizationService.handleMembershipWebhookDeleted( - event.data, - elevated - ) + // Pour l'instant, nous ne supprimons pas les liens utilisateur-organisation + // C'est un choix de conception pour conserver l'historique + return { + message: `Membership deletion registered but not processed (link preserved)`, + success: true, + } } // Handle user events - else if (event.type === 'user.created') { - return await userService.handleWebhookCreated(event.data, elevated) - } else if (event.type === 'user.updated') { - return await userService.handleWebhookUpdated(event.data, elevated) + else if (event.type === 'user.created' || event.type === 'user.updated') { + // Récupérer l'utilisateur complet depuis Clerk + const clerkAPI = await clerkClient() + const userId = event.data.id as string + const completeUser = await clerkAPI.users.getUser(userId) + + const user = await syncUserToPocketBase(completeUser) + return { + message: `User ${user.name} (${user.id}) synchronized successfully`, + success: true, + } } else if (event.type === 'user.deleted') { - return await userService.handleWebhookDeleted(event.data, elevated) + // Pour l'instant, nous ne supprimons pas réellement les utilisateurs + // C'est un choix de conception pour garder l'historique + return { + message: `User deletion registered but not processed (data preserved)`, + success: true, + } } // Unknown event type diff --git a/src/app/actions/services/pocketbase/api_client/base_service.ts b/src/app/actions/services/pocketbase/api_client/base_service.ts new file mode 100644 index 0000000..3db1e1d --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/base_service.ts @@ -0,0 +1,198 @@ +/** + * Generic base service for PocketBase CRUD operations + */ + +import { z } from 'zod' + +import { + getPocketBase, + handlePocketBaseError, + CollectionMethodOptions, + defaultCollectionMethodOptions, + validateWithZod, +} from './client' +import { ListResult, QueryParams } from './types' + +/** + * Base service class for PocketBase collections + * Provides generic CRUD operations for any collection + */ +export class BaseService { + protected readonly collectionName: string + protected readonly schema: z.ZodType + protected readonly createSchema: z.ZodType + protected readonly updateSchema: z.ZodType + protected readonly listSchema: z.ZodType> + + /** + * Constructor for BaseService + * + * @param collectionName - The name of the PocketBase collection + * @param schema - Zod schema for validating records + * @param createSchema - Zod schema for validating create inputs + * @param updateSchema - Zod schema for validating update inputs + */ + constructor( + collectionName: string, + schema: z.ZodType, + createSchema: z.ZodType, + updateSchema: z.ZodType + ) { + this.collectionName = collectionName + this.schema = schema + this.createSchema = createSchema + this.updateSchema = updateSchema + this.listSchema = z.object({ + items: z.array(this.schema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + } + + /** + * Get a single record by ID + * + * @param id - The ID of the record to retrieve + * @param options - Optional configuration for the request + * @returns The record + */ + async getById( + id: string, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise { + try { + const pb = getPocketBase() + const record = await pb.collection(this.collectionName).getOne(id) + + return options.validateOutput === false + ? (record as T) + : validateWithZod(this.schema, record) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Get a list of records + * + * @param params - Query parameters for filtering, sorting, etc. + * @param options - Optional configuration for the request + * @returns A list result containing the records + */ + async getList( + params: QueryParams = {}, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise> { + try { + const pb = getPocketBase() + const result = await pb + .collection(this.collectionName) + .getList(params.page, params.perPage, { + expand: params.expand, + filter: params.filter, + sort: params.sort, + }) + + return options.validateOutput === false + ? (result as ListResult) + : validateWithZod(this.listSchema, result) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Create a new record + * + * @param data - The data for the new record + * @param options - Optional configuration for the request + * @returns The created record + */ + async create( + data: CreateInput, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise { + try { + // Validate input data + const validatedData = validateWithZod(this.createSchema, data) + + const pb = getPocketBase() + const record = await pb + .collection(this.collectionName) + .create(validatedData as Record) + + return options.validateOutput === false + ? (record as T) + : validateWithZod(this.schema, record) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Update an existing record + * + * @param id - The ID of the record to update + * @param data - The data to update + * @param options - Optional configuration for the request + * @returns The updated record + */ + async update( + id: string, + data: UpdateInput, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise { + try { + // Validate input data + const validatedData = validateWithZod(this.updateSchema, data) + + const pb = getPocketBase() + const record = await pb + .collection(this.collectionName) + .update(id, validatedData as Record) + + return options.validateOutput === false + ? (record as T) + : validateWithZod(this.schema, record) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Delete a record + * + * @param id - The ID of the record to delete + * @returns A boolean indicating success + */ + async delete(id: string): Promise { + try { + const pb = getPocketBase() + await pb.collection(this.collectionName).delete(id) + return true + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Get a count of records matching a filter + * + * @param filter - The filter to apply + * @returns The count of matching records + */ + async getCount(filter?: string): Promise { + try { + const pb = getPocketBase() + const result = await pb.collection(this.collectionName).getList(1, 1, { + filter, + }) + + return result.totalItems + } catch (error) { + handlePocketBaseError(error) + } + } +} diff --git a/src/app/actions/services/pocketbase/api_client/client.ts b/src/app/actions/services/pocketbase/api_client/client.ts new file mode 100644 index 0000000..fce042a --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/client.ts @@ -0,0 +1,121 @@ +/** + * PocketBase client for server-side API calls + */ +import PocketBase, { ClientResponseError } from 'pocketbase' +import { cache } from 'react' +import { z } from 'zod' + +/** + * Error class for PocketBase API errors + */ +export class PocketBaseApiError extends Error { + status: number + data?: Record + + constructor(message: string, status: number, data?: Record) { + super(message) + this.name = 'PocketBaseApiError' + this.status = status + this.data = data + } + + /** + * Convert a ClientResponseError to PocketBaseApiError + */ + static fromClientResponseError( + error: ClientResponseError + ): PocketBaseApiError { + return new PocketBaseApiError(error.message, error.status, error.data) + } +} + +/** + * Get a PocketBase instance (cached per request) + * This implementation bypasses cookie auth for now and relies on admin auth + */ +export const getPocketBase = cache(() => { + // Create a new PocketBase instance + const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL) + + // In a server context, we'll need to use an admin auth + // We don't use cookie auth on the server side to avoid issues with next/headers cookies + if ( + process.env.POCKETBASE_ADMIN_EMAIL && + process.env.POCKETBASE_ADMIN_PASSWORD + ) { + pb.admins + .authWithPassword( + process.env.POCKETBASE_ADMIN_EMAIL, + process.env.POCKETBASE_ADMIN_PASSWORD + ) + .catch(error => { + console.error('Failed to authenticate with PocketBase admin:', error) + }) + } + + return pb +}) + +/** + * Generic function to handle PocketBase errors + */ +export function handlePocketBaseError(error: unknown): never { + if (error instanceof ClientResponseError) { + throw PocketBaseApiError.fromClientResponseError(error) + } + + if (error instanceof Error) { + throw new PocketBaseApiError(error.message, 500) + } + + throw new PocketBaseApiError('Unknown error occurred', 500) +} + +/** + * Collection names for PocketBase + */ +export const Collections = { + ACTIVITY_LOGS: 'ActivityLog', + APP_USERS: 'AppUser', + ASSIGNMENTS: 'Assignment', + EQUIPMENT: 'Equipment', + IMAGES: 'Images', + ORGANIZATIONS: 'Organization', + PROJECTS: 'Project', +} as const + +/** + * Type-safe collection names + */ +export type CollectionName = (typeof Collections)[keyof typeof Collections] + +/** + * Validate response data with Zod schema + */ +export function validateWithZod(schema: z.ZodType, data: unknown): T { + try { + return schema.parse(data) + } catch (error) { + if (error instanceof z.ZodError) { + console.error('Validation error:', error.format()) + throw new PocketBaseApiError('Data validation failed', 422, { + validationErrors: error.format(), + }) + } + throw error + } +} + +/** + * Type for the collection method options + */ +export interface CollectionMethodOptions { + validateOutput?: boolean +} + +/** + * Default options for collection methods + */ +export const defaultCollectionMethodOptions: CollectionMethodOptions = { + validateOutput: true, +} diff --git a/src/app/actions/services/pocketbase/api_client/index.ts b/src/app/actions/services/pocketbase/api_client/index.ts new file mode 100644 index 0000000..b9e84c5 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/index.ts @@ -0,0 +1,59 @@ +/** + * PocketBase API Client exports + */ + +// Client and utilities +export * from '@/app/actions/services/pocketbase/api_client/client' +export * from '@/app/actions/services/pocketbase/api_client/base_service' + +// Types and schemas +export * from '@/app/actions/services/pocketbase/api_client/types' +export * from '@/app/actions/services/pocketbase/api_client/schemas' + +// Re-export common utility for creating service inputs +import { z } from 'zod' + +/** + * Create partial schema by making all properties optional + * Useful for update operations + */ +export function createPartialSchema( + schema: z.ZodType +): z.ZodType { + // Handle ZodObject specifically + if (schema instanceof z.ZodObject) { + return schema.partial() + } + + // For non-object schemas, we can't properly partial them + // Just return the original schema as a fallback + console.warn('Attempting to create partial schema for non-object type') + return schema as z.ZodType +} + +/** + * Create schemas for CRUD operations based on a base schema + */ +export function createServiceSchemas(baseSchema: z.ZodType) { + // For create operations, strip system fields + const createSchema = + baseSchema instanceof z.ZodObject + ? baseSchema.omit({ + collectionId: true, + collectionName: true, + created: true, + id: true, + updated: true, + }) + : baseSchema + + // For update operations, make all properties optional + // We need to cast the schema to avoid TypeScript errors + const updateSchema = createPartialSchema(createSchema as z.ZodType) + + return { + baseSchema, + createSchema, + updateSchema, + } +} diff --git a/src/app/actions/services/pocketbase/api_client/schemas.ts b/src/app/actions/services/pocketbase/api_client/schemas.ts new file mode 100644 index 0000000..bed285f --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/schemas.ts @@ -0,0 +1,154 @@ +/** + * Zod schemas for PocketBase data models + * These schemas are used for validation of data going in and out of PocketBase + */ +import { z } from 'zod' + +/** + * Base schema for all PocketBase records + */ +export const baseRecordSchema = z.object({ + collectionId: z.string().optional(), + collectionName: z.string().optional(), + created: z.string().datetime(), + id: z.string(), + updated: z.string().datetime(), +}) + +/** + * Organization schema + */ +export const organizationSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + clerkId: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + name: z.string(), + phone: z.string().optional().or(z.literal('')), + priceId: z.string().optional().or(z.literal('')), + settings: z.record(z.string(), z.unknown()).optional().default({}), + stripeCustomerId: z.string().optional().or(z.literal('')), + subscriptionId: z.string().optional().or(z.literal('')), + subscriptionStatus: z.string().optional().or(z.literal('')), +}) + +/** + * AppUser schema + */ +export const appUserSchema = baseRecordSchema.extend({ + clerkId: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + emailVisibility: z.boolean().optional().default(true), + isAdmin: z.boolean().optional().default(false), + lastLogin: z.string().datetime().optional().or(z.literal('')), + metadata: z + .object({ + createdAt: z.number().optional(), + externalAccounts: z + .array( + z.object({ + email: z.string().email(), + imageUrl: z.string().url(), + provider: z.string(), + providerUserId: z.string(), + }) + ) + .optional(), + hasCompletedOnboarding: z.boolean().optional(), + lastActiveAt: z.number().optional(), + onboardingCompletedAt: z.string().optional(), + public: z + .object({ + hasCompletedOnboarding: z.boolean(), + onboardingCompletedAt: z.string(), + }) + .optional(), + updatedAt: z.number().optional(), + }) + .optional() + .default({}), + name: z.string().optional().or(z.literal('')), + organizations: z.string().optional().or(z.literal('')), + role: z.string().optional().or(z.literal('')), + verified: z.boolean().optional().default(false), +}) + +/** + * Equipment schema + */ +export const equipmentSchema = baseRecordSchema.extend({ + acquisitionDate: z.string().datetime().optional().or(z.literal('')), + name: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + parentEquipment: z.string().optional().or(z.literal('')), + qrNfcCode: z.string(), + tags: z.string().optional().or(z.literal('')), +}) + +/** + * Project schema + */ +export const projectSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + endDate: z.string().datetime().optional().or(z.literal('')), + name: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + startDate: z.string().datetime().optional().or(z.literal('')), +}) + +/** + * Assignment schema + */ +export const assignmentSchema = baseRecordSchema.extend({ + assignedToProject: z.string().optional().or(z.literal('')), + assignedToUser: z.string().optional().or(z.literal('')), + endDate: z.string().datetime().optional().or(z.literal('')), + equipment: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + startDate: z.string().datetime(), +}) + +/** + * ActivityLog schema + */ +export const activityLogSchema = baseRecordSchema.extend({ + equipment: z.string().optional().or(z.literal('')), + metadata: z.record(z.string(), z.unknown()).optional().default({}), + organization: z.string().optional().or(z.literal('')), + user: z.string().optional().or(z.literal('')), +}) + +/** + * Image schema + */ +export const imageSchema = baseRecordSchema.extend({ + alt: z.string().optional().or(z.literal('')), + caption: z.string().optional().or(z.literal('')), + image: z.string(), + title: z.string().optional().or(z.literal('')), +}) + +/** + * List result schema (generic) + */ +export const listResultSchema = (itemSchema: T) => + z.object({ + items: z.array(itemSchema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + +/** + * Query parameters schema + */ +export const queryParamsSchema = z.object({ + expand: z.string().optional(), + filter: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), + sort: z.string().optional(), +}) diff --git a/src/app/actions/services/pocketbase/api_client/types.ts b/src/app/actions/services/pocketbase/api_client/types.ts new file mode 100644 index 0000000..a53d27a --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/types.ts @@ -0,0 +1,143 @@ +/** + * Core types for PocketBase data models + * These types represent the schema of our database collections + */ + +/** + * Base type for all PocketBase records + */ +export interface BaseRecord { + id: string + created: string + updated: string + collectionId?: string + collectionName?: string +} + +/** + * Organization record + */ +export interface Organization extends BaseRecord { + name: string + email: string | '' + phone: string | '' + address: string | '' + settings: Record + clerkId: string + stripeCustomerId: string | '' + subscriptionId: string | '' + subscriptionStatus: string | '' + priceId: string | '' +} + +/** + * AppUser record + */ +export interface AppUser extends BaseRecord { + email: string | '' + emailVisibility: boolean + verified: boolean + name: string | '' + role: string | '' + isAdmin: boolean + lastLogin: string | '' + clerkId: string + organizations: string | '' + metadata: { + createdAt: number + externalAccounts?: Array<{ + email: string + imageUrl: string + provider: string + providerUserId: string + }> + hasCompletedOnboarding?: boolean + lastActiveAt?: number + onboardingCompletedAt?: string + public?: { + hasCompletedOnboarding: boolean + onboardingCompletedAt: string + } + updatedAt?: number + } +} + +/** + * Equipment record + */ +export interface Equipment extends BaseRecord { + organization: string + name: string + qrNfcCode: string + tags: string | '' + notes: string | '' + acquisitionDate: string | '' + parentEquipment?: string | '' +} + +/** + * Project record + */ +export interface Project extends BaseRecord { + name: string + address: string | '' + notes: string | '' + startDate: string | '' + endDate: string | '' + organization: string +} + +/** + * Assignment record + */ +export interface Assignment extends BaseRecord { + organization: string + equipment: string + assignedToUser?: string | '' + assignedToProject?: string | '' + startDate: string + endDate: string | '' + notes: string | '' +} + +/** + * ActivityLog record + */ +export interface ActivityLog extends BaseRecord { + organization?: string | '' + user?: string | '' + equipment?: string | '' + metadata: Record +} + +/** + * Image record + */ +export interface Image extends BaseRecord { + title: string | '' + alt: string | '' + caption: string | '' + image: string +} + +/** + * PocketBase response types + */ +export interface ListResult { + page: number + perPage: number + totalItems: number + totalPages: number + items: T[] +} + +/** + * Generic query parameters for list operations + */ +export interface QueryParams { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} diff --git a/src/app/actions/services/pocketbase/app-user/auth.ts b/src/app/actions/services/pocketbase/app-user/auth.ts deleted file mode 100644 index f4e0d3c..0000000 --- a/src/app/actions/services/pocketbase/app-user/auth.ts +++ /dev/null @@ -1,44 +0,0 @@ -'use server' - -import { getAppUserByClerkId } from '@/app/actions/services/pocketbase/app-user/core' -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { SecurityError } from '@/app/actions/services/securyUtilsTools' -import { AppUser } from '@/types/types_pocketbase' - -/** - * Authentication-related functions for AppUsers - */ - -/** - * Update AppUser's last login time - * This is typically called during authentication flows - */ -export async function updateAppUserLastLogin( - clerkId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Find user by clerkId - const appUser = await getAppUserByClerkId(clerkId) - if (!appUser) { - throw new SecurityError('AppUser not found') - } - - // Update lastLogin timestamp - return await pb.collection('AppUser').update(appUser.id, { - lastLogin: new Date().toISOString(), - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.updateAppUserLastLogin') - } -} diff --git a/src/app/actions/services/pocketbase/app-user/core.ts b/src/app/actions/services/pocketbase/app-user/core.ts deleted file mode 100644 index 99adbc9..0000000 --- a/src/app/actions/services/pocketbase/app-user/core.ts +++ /dev/null @@ -1,206 +0,0 @@ -'use server' - -import { - _updateAppUser, - _createAppUser, - _deleteAppUser, -} from '@/app/actions/services/pocketbase/app-user/internal' -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateCurrentUser, - validateOrganizationAccess, - validateResourceAccess, -} from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - ResourceType, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { AppUser } from '@/types/types_pocketbase' - -/** - * Core AppUser operations with security validations - */ - -/** - * Get a single AppUser by ID with security validation - */ -export async function getAppUser(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('app_users').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'AppUserService.getAppUser') - } -} - -/** - * Get current authenticated AppUser profile - */ -export async function getCurrentAppUser(): Promise { - try { - // This function needs to be adjusted to work with Clerk auth - // and the custom app_users collection - const currentUser = await validateCurrentUser() - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Find the app_user record with the matching clerk ID - return await pb - .collection('AppUser') - .getFirstListItem(`clerkId="${currentUser.clerkId}"`) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.getCurrentAppUser') - } -} - -/** - * Get an AppUser by Clerk ID - typically used during authentication - */ -export async function getAppUserByClerkId(clerkId: string): Promise { - // This is primarily used during authentication flows where - // standard security checks aren't possible yet. - // However, requests should still come from server-side code only. - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb - .collection('AppUser') - .getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - return handlePocketBaseError(error, 'AppUserService.getAppUserByClerkId') - } -} - -/** - * Create a new AppUser with security checks - * This is typically controlled access for admins only - */ -export async function createAppUser( - organizationId: string, - data: Partial, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission to create users - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - } - - // todo : fix types - // Ensure organization ID is set correctly with the proper field name - return await _createAppUser({ - ...data, - organizations: organizationId ? [{ id: organizationId }] : [], // Force the correct organization ID using the relation field - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.createAppUser') - } -} - -/** - * Update an AppUser with security checks - */ -export async function updateAppUser( - id: string, - data: Partial, - elevated = false -): Promise { - try { - if (!elevated) { - // Get current authenticated user - const currentUser = await validateCurrentUser() - const currentAppUser = await getAppUserByClerkId(currentUser.clerkId) - - // Different permission checks based on who is being updated - if (id !== currentAppUser.id) { - // Updating someone else requires ADMIN permission - await validateResourceAccess( - ResourceType.USER, - id, - PermissionLevel.ADMIN - ) - } else { - // Users can update their own basic info - // But for role changes, they'd still need admin rights - if (data.role || data.isAdmin !== undefined) { - // If trying to change role or admin status, require admin permission - // Get the user's organization ID - handling possible multiple organizations - const userOrgs = currentAppUser.organizations - - if (!userOrgs || !Array.isArray(userOrgs) || userOrgs.length === 0) { - throw new SecurityError('User does not belong to any organization') - } - - // Use the first organization for permission check - const primaryOrgId = userOrgs[0].id - await validateOrganizationAccess(primaryOrgId, PermissionLevel.ADMIN) - } - } - } - - // Security: never allow changing certain fields - const sanitizedData = { ...data } - // Don't allow org changes directly - use proper organization management functions - delete (sanitizedData as Record).organizations - - if (!elevated) { - // Only elevated calls (webhooks) can change the clerkId - delete sanitizedData.clerkId - } - - return await _updateAppUser(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.updateAppUser') - } -} - -/** - * Delete an AppUser with admin permission check - */ -export async function deleteAppUser( - id: string, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for user deletion - await validateResourceAccess(ResourceType.USER, id, PermissionLevel.ADMIN) - } - - return await _deleteAppUser(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.deleteAppUser') - } -} diff --git a/src/app/actions/services/pocketbase/app-user/index.ts b/src/app/actions/services/pocketbase/app-user/index.ts deleted file mode 100644 index 22f2828..0000000 --- a/src/app/actions/services/pocketbase/app-user/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * AppUser service - public exports - */ - -// Core operations -export { - getAppUser, - getCurrentAppUser, - getAppUserByClerkId, - createAppUser, - updateAppUser, - deleteAppUser, -} from '@/app/actions/services/pocketbase/app-user/core' - -// Search functions -export { - getAppUsersList, - getAppUsersByOrganization, - getAppUserCount, - searchAppUsers, -} from '@/app/actions/services/pocketbase/app-user/search' - -// Authentication functions -export { updateAppUserLastLogin } from '@/app/actions/services/pocketbase/app-user/auth' - -// Webhook handlers -export { - handleWebhookCreated, - handleWebhookUpdated, - handleWebhookDeleted, -} from '@/app/actions/services/pocketbase/app-user/webhook-handlers' - -// Internal functions that might be used by other services -export { getByClerkId } from '@/app/actions/services/pocketbase/app-user/internal' diff --git a/src/app/actions/services/pocketbase/app-user/internal.ts b/src/app/actions/services/pocketbase/app-user/internal.ts deleted file mode 100644 index 9591df4..0000000 --- a/src/app/actions/services/pocketbase/app-user/internal.ts +++ /dev/null @@ -1,124 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { AppUser } from '@/types/types_pocketbase' - -/** - * Internal methods for AppUser management - * These methods have no security checks and should only be called - * from secured public API methods - */ - -/** - * Internal: Update AppUser without security checks - * @param id AppUser ID - * @param data AppUser data - */ -export async function _updateAppUser( - id: string, - data: Partial -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('AppUser').update(id, data) - } catch (error) { - return handlePocketBaseError(error, 'AppUserService._updateAppUser') - } -} - -/** - * Internal: Create AppUser without security checks - * @param data AppUser data - */ -export async function _createAppUser(data: Partial): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - console.info('Creating new AppUser with data:', data) - - // Make sure clerkId is included - if (!data.clerkId) { - throw new Error('clerkId is required when creating a new AppUser') - } - - const newUser = await pb.collection('AppUser').create(data) - console.info('New AppUser created:', newUser) - - // todo : fix types - return newUser as unknown as AppUser - } catch (error) { - console.error('Error creating app user:', error) - throw error - } -} - -/** - * Internal: Delete AppUser without security checks - * @param id AppUser ID - */ -export async function _deleteAppUser(id: string): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('AppUser').delete(id) - return true - } catch (error) { - console.error('Error deleting app user:', error) - throw error - } -} - -/** - * Get an AppUser by Clerk ID - * @param clerkId Clerk user ID - * @returns AppUser record or null if not found - */ -export async function getByClerkId(clerkId: string): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - console.info(`Searching for AppUser with clerkId: ${clerkId}`) - - // Try to find the user - try { - const user = await pb - .collection('AppUser') - .getFirstListItem(`clerkId="${clerkId}"`) - - console.info('User found:', user) - - // todo : fix types - return user as unknown as AppUser - } catch (error) { - // Check if this is a "not found" error - if ( - error instanceof Error && - (error.message.includes('404') || error.message.includes('not found')) - ) { - console.info(`No user found with clerkId: ${clerkId}`) - return null - } - // Otherwise rethrow the error - throw error - } - } catch (error) { - console.error('Error fetching app user by clerk ID:', error) - return null - } -} diff --git a/src/app/actions/services/pocketbase/app-user/search.ts b/src/app/actions/services/pocketbase/app-user/search.ts deleted file mode 100644 index ec5fc10..0000000 --- a/src/app/actions/services/pocketbase/app-user/search.ts +++ /dev/null @@ -1,148 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { validateOrganizationAccess } from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { ListOptions, ListResult, AppUser } from '@/types/types_pocketbase' - -/** - * Search and listing functions for AppUsers - */ - -/** - * Get AppUsers list with pagination and security checks - */ -export async function getAppUsersList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - needs at least READ permission - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = `organizations ~ "${organizationId}"${additionalFilter ? ` && (${additionalFilter})` : ''}` - - return await pb.collection('AppUser').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.getAppUsersList') - } -} - -/** - * Get all AppUsers for an organization with security checks - */ -export async function getAppUsersByOrganization( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with this organization in their organizations relation - return await pb.collection('AppUser').getFullList({ - filter: `organizations ~ "${organizationId}"`, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AppUserService.getAppUsersByOrganization' - ) - } -} - -/** - * Get the count of AppUsers in an organization - */ -export async function getAppUserCount(organizationId: string): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with this organization in their organizations relation - const result = await pb.collection('AppUser').getList(1, 1, { - filter: `organizations ~ "${organizationId}"`, - skipTotal: false, - }) - - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.getAppUserCount') - } -} - -/** - * Search for AppUsers in the organization - */ -export async function searchAppUsers( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with search conditions - return await pb.collection('AppUser').getFullList({ - filter: pb.filter( - 'organizations ~ {:orgId} && (name ~ {:query} || email ~ {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AppUserService.searchAppUsers') - } -} diff --git a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts b/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts deleted file mode 100644 index 5bfa0b9..0000000 --- a/src/app/actions/services/pocketbase/app-user/webhook-handlers.ts +++ /dev/null @@ -1,173 +0,0 @@ -'use server' - -import { - createAppUser, - updateAppUser, - deleteAppUser, -} from '@/app/actions/services/pocketbase/app-user/core' -import { getByClerkId } from '@/app/actions/services/pocketbase/app-user/internal' -import { ClerkUserWebhookData, WebhookProcessingResult } from '@/types/webhooks' - -/** - * Handles a webhook event for user creation - * @param {ClerkUserWebhookData} data - User data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookCreated( - data: ClerkUserWebhookData, - elevated = true -): Promise { - try { - // Check if already exists - const existing = await getByClerkId(data.id) - if (existing) { - return { - message: `AppUser ${data.id} already exists`, - success: true, - } - } - - // Get primary email if available - let email = '' - if (data.email_addresses && data.email_addresses.length > 0) { - email = data.email_addresses[0].email_address - } - - // Create new AppUser - normally we'd include an organization ID, but - // for webhook-created users, we'll wait for the organization membership event - await createAppUser( - '', // Leave empty for now, will be set when user joins an organization - { - clerkId: data.id, - email, - name: `${data.first_name || ''} ${data.last_name || ''}`.trim(), - role: 'member', - verified: true, - // No password fields needed with custom collection - }, - elevated - ) - - return { - message: `Created AppUser ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process user creation webhook:', error) - return { - message: `Failed to process user creation: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for user update - * @param {ClerkUserWebhookData} data - User data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookUpdated( - data: ClerkUserWebhookData, - elevated = true -): Promise { - try { - console.info('Processing user update webhook for clerkId:', data.id) - - // Find existing user - const existing = await getByClerkId(data.id) - - if (!existing) { - console.info( - `AppUser with clerkId ${data.id} not found, creating new user` - ) - // If user doesn't exist, create it instead of updating - return await handleWebhookCreated(data, elevated) - } - - // Continue with the update logic... - console.info(`Updating existing AppUser: ${existing.id}`) - - // Get primary email if available - let email = existing.email // Default to existing - if (data.email_addresses && data.email_addresses.length > 0) { - email = data.email_addresses[0].email_address - } - - // Update user - await updateAppUser( - existing.id, - { - email, - name: - `${data.first_name || ''} ${data.last_name || ''}`.trim() || - existing.name, - }, - elevated - ) - - return { - message: `Updated AppUser ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process user update webhook:', error) - return { - message: `Failed to process user update: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles the user.deleted webhook event from Clerk - * @param data The webhook data from Clerk - * @param elevated Whether to use elevated permissions - * @returns Result of the operation - */ -export async function handleWebhookDeleted( - data: ClerkUserWebhookData, - elevated = true -): Promise { - try { - const clerkId = data.id - console.info(`Processing user deletion webhook for clerkId: ${clerkId}`) - - // Find existing user - const existing = await getByClerkId(clerkId) - - // If user doesn't exist in PocketBase, just log and return success - if (!existing) { - console.info( - `AppUser with clerkId ${clerkId} not found in PocketBase. Nothing to delete.` - ) - return { - message: `No user found with clerkId ${clerkId}. No action needed.`, - success: true, - } - } - - console.info( - `Found AppUser with id ${existing.id} and clerkId ${clerkId}. Deleting...` - ) - - // Delete the user from PocketBase - await deleteAppUser(existing.id, elevated) - - console.info( - `Successfully deleted AppUser with id ${existing.id} and clerkId ${clerkId}` - ) - - return { - message: `Successfully deleted user with clerkId ${clerkId}`, - success: true, - } - } catch (error) { - console.error('Failed to process user deletion webhook:', error) - return { - message: `Failed to process user deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} diff --git a/src/app/actions/services/pocketbase/app_user_service.ts b/src/app/actions/services/pocketbase/app_user_service.ts new file mode 100644 index 0000000..cf16327 --- /dev/null +++ b/src/app/actions/services/pocketbase/app_user_service.ts @@ -0,0 +1,183 @@ +'use server' + +import { z } from 'zod' + +import { BaseService, Collections, createServiceSchemas } from './api_client' +import { appUserSchema } from './api_client/schemas' +import { AppUser } from './api_client/types' + +// Create schemas for app user operations +const { createSchema, updateSchema } = createServiceSchemas(appUserSchema) + +// Types based on the schemas +export type AppUserCreateInput = z.infer +export type AppUserUpdateInput = z.infer + +/** + * Service for AppUser-related operations + */ +export class AppUserService extends BaseService< + AppUser, + AppUserCreateInput, + AppUserUpdateInput +> { + constructor() { + super(Collections.APP_USERS, appUserSchema, createSchema, updateSchema) + } + + /** + * Find a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @returns The user or null if not found + */ + async findByClerkId(clerkId: string): Promise { + try { + const result = await this.getList({ + filter: `clerkId = "${clerkId}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding user by clerkId:', error) + return null + } + } + + /** + * Check if a user with the given Clerk ID exists + * + * @param clerkId - The Clerk user ID + * @returns True if the user exists + */ + async existsByClerkId(clerkId: string): Promise { + try { + const count = await this.getCount(`clerkId = "${clerkId}"`) + return count > 0 + } catch (error) { + console.error('Error checking if user exists by clerkId:', error) + return false + } + } + + /** + * Create or update a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @param data - The user data + * @returns The created or updated user + */ + async createOrUpdateByClerkId( + clerkId: string, + data: Omit + ): Promise { + const existing = await this.findByClerkId(clerkId) + + if (existing) { + return this.update(existing.id, { + ...data, + clerkId, + }) + } + + return this.create({ + ...data, + clerkId, + }) + } + + /** + * Link a user to an organization + * + * @param userId - The user ID + * @param organizationId - The organization ID + * @param role - The user's role in the organization + * @returns The updated user + */ + async linkToOrganization( + userId: string, + organizationId: string, + role: string = 'member' + ): Promise { + return this.update(userId, { + organizations: organizationId, + role, + }) + } + + /** + * Get all users in an organization + * + * @param organizationId - The organization ID + * @returns List of users in the organization + */ + async getByOrganization(organizationId: string): Promise { + try { + const result = await this.getList({ + filter: `organizations = "${organizationId}"`, + }) + + return result.items + } catch (error) { + console.error('Error getting users by organization:', error) + return [] + } + } +} + +// Singleton instance +let appUserServiceInstance: AppUserService | null = null + +/** + * Get the AppUserService instance + * + * @returns The AppUserService instance + */ +export function getAppUserService(): AppUserService { + if (!appUserServiceInstance) { + appUserServiceInstance = new AppUserService() + } + return appUserServiceInstance +} + +/** + * Find a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @returns The user or null if not found + */ +export async function findUserByClerkId( + clerkId: string +): Promise { + return getAppUserService().findByClerkId(clerkId) +} + +/** + * Create or update a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @param data - The user data + * @returns The created or updated user + */ +export async function createOrUpdateUserByClerkId( + clerkId: string, + data: Omit +): Promise { + return getAppUserService().createOrUpdateByClerkId(clerkId, data) +} + +/** + * Link a user to an organization + * + * @param userId - The user ID + * @param organizationId - The organization ID + * @param role - The user's role in the organization + * @returns The updated user + */ +export async function linkUserToOrganization( + userId: string, + organizationId: string, + role: string = 'member' +): Promise { + return getAppUserService().linkToOrganization(userId, organizationId, role) +} diff --git a/src/app/actions/services/pocketbase/assignmentService.ts b/src/app/actions/services/pocketbase/assignmentService.ts deleted file mode 100644 index 9bb2af9..0000000 --- a/src/app/actions/services/pocketbase/assignmentService.ts +++ /dev/null @@ -1,466 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, -} from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - ResourceType, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { Assignment, ListOptions, ListResult } from '@/types/types_pocketbase' - -/** - * Get a single assignment by ID with security validation - */ -export async function getAssignment(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('assignments').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'AssignmentService.getAssignment') - } -} - -/** - * Get assignments list with pagination and security checks - */ -export async function getAssignmentsList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = await createOrganizationFilter( - organizationId, - additionalFilter - ) - - return await pb.collection('assignments').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.getAssignmentsList') - } -} - -/** - * Get active assignments for an organization with security checks - * Active assignments have startDate ≤ current date and no endDate or endDate ≥ current date - */ -export async function getActiveAssignments( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - - return await pb.collection('assignments').getFullList({ - expand: 'equipmentId,assignedToUserId,assignedToProjectId', - filter: pb.filter( - 'organizationId = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { now, orgId: organizationId } - ), - sort: '-created', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getActiveAssignments' - ) - } -} - -/** - * Get current assignment for a specific equipment with security checks - */ -export async function getCurrentEquipmentAssignment( - equipmentId: string -): Promise { - try { - // Security check - validates access to the equipment - const { organizationId } = await validateResourceAccess( - ResourceType.EQUIPMENT, - equipmentId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - - // Include organization check for extra security - const assignments = await pb.collection('assignments').getList(1, 1, { - expand: 'equipmentId,assignedToUserId,assignedToProjectId', - filter: pb.filter( - 'organizationId = {:orgId} && equipmentId = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { equipId: equipmentId, now, orgId: organizationId } - ), - sort: '-created', - }) - - return assignments.items.length > 0 - ? (assignments.items[0] as Assignment) - : null - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getCurrentEquipmentAssignment' - ) - } -} - -/** - * Get assignments for a user with security checks - */ -export async function getUserAssignments( - userId: string -): Promise { - try { - // Security check - validates access to the user - const { organizationId } = await validateResourceAccess( - ResourceType.USER, - userId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Include organization filter for security - return await pb.collection('assignments').getFullList({ - expand: 'equipmentId,assignedToProjectId', - filter: await createOrganizationFilter( - organizationId, - `assignedToUserId="${userId}"` - ), - sort: '-created', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.getUserAssignments') - } -} - -/** - * Get assignments for a project with security checks - */ -export async function getProjectAssignments( - projectId: string -): Promise { - try { - // Security check - validates access to the project - const { organizationId } = await validateResourceAccess( - ResourceType.PROJECT, - projectId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Include organization filter for security - return await pb.collection('assignments').getFullList({ - expand: 'equipmentId,assignedToUserId', - filter: await createOrganizationFilter( - organizationId, - `assignedToProjectId=${projectId}` - ), - sort: '-created', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getProjectAssignments' - ) - } -} - -/** - * Create a new assignment with security checks - */ -export async function createAssignment( - organizationId: string, - data: Pick< - Partial, - | 'equipmentId' - | 'assignedToUserId' - | 'assignedToProjectId' - | 'startDate' - | 'endDate' - | 'notes' - > -): Promise { - try { - // Security check - requires WRITE permission - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - // If equipment is provided, verify access to it - if (data.equipmentId) { - await validateResourceAccess( - ResourceType.EQUIPMENT, - data.equipmentId, - PermissionLevel.READ - ) - } - - // If assignedToUser is provided, verify access to that user - if (data.assignedToUserId) { - await validateResourceAccess( - ResourceType.USER, - data.assignedToUserId, - PermissionLevel.READ - ) - } - - // If assignedToProject is provided, verify access to that project - if (data.assignedToProjectId) { - await validateResourceAccess( - ResourceType.PROJECT, - data.assignedToProjectId, - PermissionLevel.READ - ) - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set correctly - return await pb.collection('assignments').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.createAssignment') - } -} - -/** - * Update an assignment with security checks - */ -export async function updateAssignment( - id: string, - data: Pick< - Partial, - | 'equipmentId' - | 'assignedToUserId' - | 'assignedToProjectId' - | 'startDate' - | 'endDate' - | 'notes' - > -): Promise { - try { - // Security check - requires WRITE permission for the assignment - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.WRITE - ) - - // Additional validations for related resources - if (data.equipmentId) { - await validateResourceAccess( - ResourceType.EQUIPMENT, - data.equipmentId, - PermissionLevel.READ - ) - } - - if (data.assignedToUserId) { - await validateResourceAccess( - ResourceType.USER, - data.assignedToUserId, - PermissionLevel.READ - ) - } - - if (data.assignedToProjectId) { - await validateResourceAccess( - ResourceType.PROJECT, - data.assignedToProjectId, - PermissionLevel.READ - ) - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Never allow changing the organization - const sanitizedData = { ...data } - // Use type assertion with more specific type - delete (sanitizedData as Record).organizationId - - return await pb.collection('assignments').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.updateAssignment') - } -} - -/** - * Delete an assignment with security checks - */ -export async function deleteAssignment(id: string): Promise { - try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('assignments').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.deleteAssignment') - } -} - -/** - * Complete an assignment by setting its end date to now with security checks - */ -export async function completeAssignment(id: string): Promise { - try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.ASSIGNMENT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('assignments').update(id, { - endDate: new Date().toISOString(), - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'AssignmentService.completeAssignment') - } -} - -/** - * Get assignment history for an equipment with security checks - */ -export async function getEquipmentAssignmentHistory( - equipmentId: string -): Promise { - try { - // Security check - validates access to the equipment - const { organizationId } = await validateResourceAccess( - ResourceType.EQUIPMENT, - equipmentId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Include organization filter for security - return await pb.collection('assignments').getFullList({ - expand: 'assignedToUserId,assignedToProjectId', - filter: await createOrganizationFilter( - organizationId, - `equipmentId="${equipmentId}"` - ), - sort: '-startDate', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'AssignmentService.getEquipmentAssignmentHistory' - ) - } -} diff --git a/src/app/actions/services/pocketbase/baseService.ts b/src/app/actions/services/pocketbase/baseService.ts deleted file mode 100644 index ddd34f2..0000000 --- a/src/app/actions/services/pocketbase/baseService.ts +++ /dev/null @@ -1,195 +0,0 @@ -import 'server-only' -import PocketBase from 'pocketbase' - -// Singleton pattern for PocketBase instance -let instance: PocketBase | null = null - -/** - * Initialize and authenticate with PocketBase - * Uses server-side authentication with an admin token - * - * @returns {Promise} Authenticated PocketBase instance or null if authentication fails - */ -export const getPocketBase = async (): Promise => { - // Return existing instance if valid - if (instance?.authStore?.isValid) { - return instance - } - - // Get credentials from environment variables - // PB_TOKEN_API_ADMIN - // PB_API_URL - const token = process.env.PB_TOKEN_API_ADMIN - const url = process.env.PB_API_URL - - if (!token || !url) { - console.error('Missing PocketBase credentials in environment variables') - return null - } - - // Create new PocketBase instance - instance = new PocketBase(url) - instance.authStore.save(token, null) - instance.autoCancellation(false) - - return instance -} - -/** - * Error handler for PocketBase operations - * @param error The caught error - * @param context Optional context information for better error reporting - */ -export const handlePocketBaseError = ( - error: unknown, - context?: string -): never => { - const contextMsg = context ? ` [${context}]` : '' - console.error(`PocketBase error${contextMsg}:`, error) - - if (error instanceof Error) { - throw new Error( - `PocketBase operation failed${contextMsg}: ${error.message}` - ) - } - - throw new Error(`Unknown PocketBase error${contextMsg}`) -} - -/** - * Type for record data - */ -export type RecordData = Record - -/** - * Base method for creating records with permission handling - * @param collection - Collection name - * @param data - Record data - * @param elevated - Whether this operation has elevated permissions (e.g., from webhook) - */ -export async function createRecord( - collection: string, - data: RecordData, - // We keep the elevated param for future implementation of permission checks - // eslint-disable-next-line @typescript-eslint/no-unused-vars - elevated = false -) { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to initialize PocketBase client') - } - - // Create the record with the PocketBase instance - return await pb.collection(collection).create(data) - } catch (error) { - console.error(`Error creating record in ${collection}:`, error) - throw error - } -} - -/** - * Base method for updating records with permission handling - * @param collection - Collection name - * @param id - Record ID - * @param data - Updated data - * @param elevated - Whether this operation has elevated permissions - */ -export async function updateRecord( - collection: string, - id: string, - data: RecordData, - // We keep the elevated param for future implementation of permission checks - // eslint-disable-next-line @typescript-eslint/no-unused-vars - elevated = false -) { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to initialize PocketBase client') - } - - // Update the record with the PocketBase instance - return await pb.collection(collection).update(id, data) - } catch (error) { - console.error(`Error updating record in ${collection}:`, error) - throw error - } -} - -/** - * Base method for deleting records with permission handling - * @param collection - Collection name - * @param id - Record ID - * @param elevated - Whether this operation has elevated permissions - */ -export async function deleteRecord( - collection: string, - id: string, - // We keep the elevated param for future implementation of permission checks - // eslint-disable-next-line @typescript-eslint/no-unused-vars - elevated = false -) { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to initialize PocketBase client') - } - - // Delete the record with the PocketBase instance - return await pb.collection(collection).delete(id) - } catch (error) { - console.error(`Error deleting record in ${collection}:`, error) - throw error - } -} - -/** - * Base method for retrieving a single record by ID - * @param collection - Collection name - * @param id - Record ID - */ -export async function getRecordById(collection: string, id: string) { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to initialize PocketBase client') - } - - return await pb.collection(collection).getOne(id) - } catch (error) { - console.error(`Error getting record by ID in ${collection}:`, error) - throw error - } -} - -/** - * Base method for listing records with filters - * @param collection - Collection name - * @param page - Page number - * @param perPage - Items per page - * @param filter - Filter string - * @param sort - Sort string - */ -export async function listRecords( - collection: string, - page = 1, - perPage = 50, - filter = '', - sort = '' -) { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to initialize PocketBase client') - } - - return await pb.collection(collection).getList(page, perPage, { - filter, - sort, - }) - } catch (error) { - console.error(`Error listing records in ${collection}:`, error) - throw error - } -} diff --git a/src/app/actions/services/pocketbase/equipmentService.ts b/src/app/actions/services/pocketbase/equipmentService.ts deleted file mode 100644 index 6a38c3e..0000000 --- a/src/app/actions/services/pocketbase/equipmentService.ts +++ /dev/null @@ -1,363 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, -} from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - ResourceType, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { Equipment, ListOptions, ListResult } from '@/types/types_pocketbase' - -/** - * Get a single equipment item by ID with security validation - */ -export async function getEquipment(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess( - ResourceType.EQUIPMENT, - id, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('equipment').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'EquipmentService.getEquipment') - } -} - -/** - * Get equipment by QR/NFC code with organization validation - */ -export async function getEquipmentByCode( - organizationId: string, - qrNfcCode: string -): Promise { - try { - // Security check - validates user belongs to this organization - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter for security - const filter = await createOrganizationFilter( - organizationId, - `qrNfcCode="${qrNfcCode}"` - ) - return await pb.collection('equipment').getFirstListItem(filter) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getEquipmentByCode') - } -} - -/** - * Get equipment list with pagination and security checks - */ -export async function getEquipmentList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = await createOrganizationFilter( - organizationId, - additionalFilter - ) - - return await pb.collection('equipment').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getEquipmentList') - } -} - -/** - * Get all equipment for an organization with security check - */ -export async function getOrganizationEquipment( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter - fixed field name to match interface - const filter = `organizationId=${organizationId}` - - return await pb.collection('equipment').getFullList({ - filter, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'EquipmentService.getOrganizationEquipment' - ) - } -} - -/** - * Create a new equipment item with permission check - */ -export async function createEquipment( - organizationId: string, - data: Pick< - Partial, - | 'name' - | 'qrNfcCode' - | 'tags' - | 'notes' - | 'acquisitionDate' - | 'parentEquipmentId' - > -): Promise { - try { - // Security check - requires WRITE permission - removed unused user variable - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set and matches the authenticated user's org - // Fixed field name to match interface - return await pb.collection('equipment').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.createEquipment') - } -} - -/** - * Update an equipment item with permission and ownership checks - */ -export async function updateEquipment( - id: string, - data: Pick< - Partial, - | 'name' - | 'qrNfcCode' - | 'tags' - | 'notes' - | 'acquisitionDate' - | 'parentEquipmentId' - > -): Promise { - try { - // Security check - validates organization and requires WRITE permission - await validateResourceAccess( - ResourceType.EQUIPMENT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Never allow changing the organization - const sanitizedData = { ...data } - // Fixed 'any' type and field name - delete (sanitizedData as Record).organizationId - - return await pb.collection('equipment').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.updateEquipment') - } -} - -/** - * Delete an equipment item with permission check - */ -export async function deleteEquipment(id: string): Promise { - try { - // Security check - requires ADMIN permission for deletion - await validateResourceAccess( - ResourceType.EQUIPMENT, - id, - PermissionLevel.ADMIN - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('equipment').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.deleteEquipment') - } -} - -/** - * Get child equipment (items that have this equipment as parent) - */ -export async function getChildEquipment( - parentId: string -): Promise { - try { - // Security check - validates parent equipment access - const { organizationId } = await validateResourceAccess( - ResourceType.EQUIPMENT, - parentId, - PermissionLevel.READ - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter for security - fixed field name - const filter = await createOrganizationFilter( - organizationId, - `parentEquipmentId="${parentId}"` - ) - - return await pb.collection('equipment').getFullList({ - filter, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getChildEquipment') - } -} - -/** - * Generate a unique QR/NFC code - */ -export async function generateUniqueCode(): Promise { - // Generate a random alphanumeric code - const prefix = 'EQ' - const randomPart = Math.random().toString(36).substring(2, 10).toUpperCase() - return `${prefix}-${randomPart}` -} - -/** - * Get equipment count for an organization - */ -export async function getEquipmentCount( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const result = await pb.collection('equipment').getList(1, 1, { - filter: `organizationId="${organizationId}"`, // Fixed field name - skipTotal: false, - }) - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.getEquipmentCount') - } -} - -/** - * Search equipment by name or tag within organization - */ -export async function searchEquipment( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('equipment').getFullList({ - filter: pb.filter( - 'organizationId = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'EquipmentService.searchEquipment') - } -} diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts new file mode 100644 index 0000000..a6653c2 --- /dev/null +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -0,0 +1,204 @@ +'use server' + +import { z } from 'zod' + +import { BaseService, Collections, createServiceSchemas } from './api_client' +import { equipmentSchema } from './api_client/schemas' +import { Equipment } from './api_client/types' + +// Re-export Equipment type +export type { Equipment } + +// Create schemas for equipment operations +const { createSchema, updateSchema } = createServiceSchemas(equipmentSchema) + +// Types based on the schemas +export type EquipmentCreateInput = z.infer +export type EquipmentUpdateInput = z.infer + +/** + * Service for Equipment-related operations + */ +export class EquipmentService extends BaseService< + Equipment, + EquipmentCreateInput, + EquipmentUpdateInput +> { + constructor() { + super(Collections.EQUIPMENT, equipmentSchema, createSchema, updateSchema) + } + + /** + * Find equipment by QR/NFC code + * + * @param qrNfcCode - The QR/NFC code to search for + * @returns The equipment or null if not found + */ + async findByQrNfcCode(qrNfcCode: string): Promise { + try { + const result = await this.getList({ + filter: `qrNfcCode = "${qrNfcCode}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding equipment by QR/NFC code:', error) + return null + } + } + + /** + * Find all equipment for an organization + * + * @param organizationId - The organization ID + * @returns Array of equipment + */ + async findByOrganization(organizationId: string): Promise { + try { + const result = await this.getList({ + filter: `organization = "${organizationId}"`, + }) + + return result.items + } catch (error) { + console.error('Error finding equipment by organization:', error) + return [] + } + } + + /** + * Find equipment by parent equipment ID + * + * @param parentEquipmentId - The parent equipment ID + * @returns Array of child equipment + */ + async findByParentEquipment(parentEquipmentId: string): Promise { + try { + const result = await this.getList({ + filter: `parentEquipment = "${parentEquipmentId}"`, + }) + + return result.items + } catch (error) { + console.error('Error finding equipment by parent equipment:', error) + return [] + } + } + + /** + * Search equipment by name, tags or notes + * + * @param organizationId - The organization ID + * @param searchTerm - Term to search for + * @returns Array of matching equipment + */ + async search( + organizationId: string, + searchTerm: string + ): Promise { + try { + // Clean up search term for use in filter + const cleanTerm = searchTerm.trim().replace(/"/g, '\\"') + + const result = await this.getList({ + filter: `organization = "${organizationId}" && (name ~ "${cleanTerm}" || tags ~ "${cleanTerm}" || notes ~ "${cleanTerm}")`, + }) + + return result.items + } catch (error) { + console.error('Error searching equipment:', error) + return [] + } + } + + /** + * Generate a new unique QR/NFC code + * + * @returns A new unique code + */ + async generateUniqueCode(): Promise { + // Generate a random alphanumeric code + const generateCode = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let result = '' + for (let i = 0; i < 10; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result + } + + // Keep generating until we find a unique one + let isUnique = false + let code = '' + + while (!isUnique) { + code = generateCode() + const existing = await this.findByQrNfcCode(code) + isUnique = existing === null + } + + return code + } +} + +// Singleton instance +let equipmentServiceInstance: EquipmentService | null = null + +/** + * Get the EquipmentService instance + * + * @returns The EquipmentService instance + */ +export function getEquipmentService(): EquipmentService { + if (!equipmentServiceInstance) { + equipmentServiceInstance = new EquipmentService() + } + return equipmentServiceInstance +} + +/** + * Find equipment by QR/NFC code + * + * @param qrNfcCode - The QR/NFC code + * @returns The equipment or null if not found + */ +export async function findEquipmentByQrNfcCode( + qrNfcCode: string +): Promise { + return getEquipmentService().findByQrNfcCode(qrNfcCode) +} + +/** + * Find all equipment for an organization + * + * @param organizationId - The organization ID + * @returns Array of equipment + */ +export async function findEquipmentByOrganization( + organizationId: string +): Promise { + return getEquipmentService().findByOrganization(organizationId) +} + +/** + * Search equipment by name, tags or notes + * + * @param organizationId - The organization ID + * @param searchTerm - Term to search for + * @returns Array of matching equipment + */ +export async function searchEquipment( + organizationId: string, + searchTerm: string +): Promise { + return getEquipmentService().search(organizationId, searchTerm) +} + +/** + * Generate a new unique QR/NFC code + * + * @returns A new unique code + */ +export async function generateUniqueEquipmentCode(): Promise { + return getEquipmentService().generateUniqueCode() +} diff --git a/src/app/actions/services/pocketbase/index.ts b/src/app/actions/services/pocketbase/index.ts new file mode 100644 index 0000000..6ef2c05 --- /dev/null +++ b/src/app/actions/services/pocketbase/index.ts @@ -0,0 +1,42 @@ +/** + * PocketBase Services + * Central export point for all PocketBase services and utilities + */ + +// Export API client core +export * from './api_client' + +// Export individual services +export * from './organization_service' +export * from './app_user_service' +export * from './equipment_service' + +// Re-export common validation utility +import { z } from 'zod' + +/** + * Validate organization ID format + * @param id - The ID to validate + * @returns Whether the ID is valid + */ +export function isValidOrganizationId(id: unknown): boolean { + return z.string().length(15).safeParse(id).success +} + +/** + * Validate user ID format + * @param id - The ID to validate + * @returns Whether the ID is valid + */ +export function isValidUserId(id: unknown): boolean { + return z.string().length(15).safeParse(id).success +} + +/** + * Validate ID format for any record + * @param id - The ID to validate + * @returns Whether the ID is valid + */ +export function isValidRecordId(id: unknown): boolean { + return z.string().length(15).safeParse(id).success +} diff --git a/src/app/actions/services/pocketbase/organization/core.ts b/src/app/actions/services/pocketbase/organization/core.ts deleted file mode 100644 index 30a07dc..0000000 --- a/src/app/actions/services/pocketbase/organization/core.ts +++ /dev/null @@ -1,346 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - _createOrganization, - _updateOrganization, - _deleteOrganization, -} from '@/app/actions/services/pocketbase/organization/internal' -import { - validateCurrentUser, - validateOrganizationAccess, -} from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { Organization, ListOptions, ListResult } from '@/types/types_pocketbase' - -/** - * Core organization operations with security validations - */ - -/** - * Get a single organization by ID with security validation - */ -export async function getOrganization(id: string): Promise { - try { - // Security check - validates user has access to this organization - await validateOrganizationAccess(id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('organizations').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'OrganizationService.getOrganization') - } -} - -/** - * Get an organization by Clerk ID with security validation - * This is primarily used during authentication - */ -export async function getOrganizationByClerkId( - clerkId: string -): Promise { - try { - // Validate current user is authenticated - const user = await validateCurrentUser() - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const organization = await pb - .collection('organizations') - .getFirstListItem(`clerkId="${clerkId}"`) - - // Check if user has access to this organization - // Get the user with expanded organizations - const userWithOrgs = await pb.collection('users').getOne(user.id, { - expand: 'organizations', - }) - - // Check if the user has access to this organization - const hasAccess = - userWithOrgs.organizations && - userWithOrgs.organizations.some( - (orgId: string) => orgId === organization.id - ) - - if (!hasAccess) { - throw new SecurityError('User does not belong to this organization') - } - - // todo : fix types - return organization as Organization - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationByClerkId' - ) - } -} - -/** - * Get organizations list for the current user - */ -export async function getUserOrganizations(): Promise { - try { - const user = await validateCurrentUser() - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fetch the user with expanded organizations - const userWithOrgs = await pb.collection('users').getOne(user.id, { - expand: 'organizations', - }) - - if (!userWithOrgs.expand?.organizations) { - return [] - } - - return userWithOrgs.expand.organizations - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getUserOrganizations' - ) - } -} - -/** - * Get organizations list with pagination - * This should only be accessible to super-admins in regular operation - */ -export async function getOrganizationsList( - options: ListOptions = {}, - elevated = false -): Promise> { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError( - 'This operation is restricted to super administrators' - ) - } - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb - .collection('organizations') - .getList(options.page || 1, options.perPage || 50, { - filter: options.filter, - sort: options.sort, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationsList' - ) - } -} - -/** - * Create a new organization - supports both regular and elevated access - */ -export async function createOrganization( - data: Partial, - elevated = false -): Promise { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError( - 'This operation is restricted to super administrators' - ) - } - } - - // For elevated access (webhooks), we bypass additional security checks - return await _createOrganization(data) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.createOrganization' - ) - } -} - -/** - * Update an organization with security validation - */ -export async function updateOrganization( - id: string, - data: Partial, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for organization updates - await validateOrganizationAccess(id, PermissionLevel.ADMIN) - - // Sanitize sensitive fields for regular users - const sanitizedData = { ...data } - - // Never allow changing the clerkId - that's a special binding - delete sanitizedData.clerkId - - // Don't allow changing Stripe-related fields directly - delete sanitizedData.stripeCustomerId - delete sanitizedData.subscriptionId - delete sanitizedData.subscriptionStatus - delete sanitizedData.priceId - - return await _updateOrganization(id, sanitizedData) - } - - // For elevated access, use the data as provided - return await _updateOrganization(id, data) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.updateOrganization' - ) - } -} - -/** - * Delete an organization - supports both regular and elevated access - */ -export async function deleteOrganization( - id: string, - elevated = false -): Promise { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError( - 'This operation is restricted to super administrators' - ) - } - } - - // For elevated access (webhooks), we bypass additional security checks - return await _deleteOrganization(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - handlePocketBaseError(error, 'OrganizationService.deleteOrganization') - return false - } -} - -/** - * Update organization subscription details - * This should only be called from Stripe webhooks, not directly by users - */ -export async function updateSubscription( - id: string, - subscriptionData: { - stripeCustomerId?: string - subscriptionId?: string - subscriptionStatus?: string - priceId?: string - }, - elevated = false -): Promise { - try { - if (!elevated) { - // For regular access, verify super-admin status - const user = await validateCurrentUser() - if (!user.isAdmin) { - throw new SecurityError('This operation is restricted') - } - } - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Verify the organization exists - const organization = await pb.collection('organizations').getOne(id) - if (!organization) { - throw new Error('Organization not found') - } - - return await pb.collection('organizations').update(id, subscriptionData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.updateSubscription' - ) - } -} - -/** - * Get current organization settings for the authenticated user - * If the user belongs to multiple organizations, takes the first active one - */ -export async function getCurrentOrganizationSettings(): Promise { - try { - // Get all organizations for the current user - const userOrganizations = await getUserOrganizations() - - if (!userOrganizations.length) { - throw new SecurityError('User does not belong to any organization') - } - - // For simplicity, we're returning the first organization - const firstOrgId = userOrganizations[0].id - - // Fetch full organization details with validated access - return await getOrganization(firstOrgId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getCurrentOrganizationSettings' - ) - } -} diff --git a/src/app/actions/services/pocketbase/organization/index.ts b/src/app/actions/services/pocketbase/organization/index.ts deleted file mode 100644 index a594e03..0000000 --- a/src/app/actions/services/pocketbase/organization/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Organization service - public exports - */ - -// Core operations -export { - getOrganization, - getOrganizationByClerkId, - getUserOrganizations, - getOrganizationsList, - createOrganization, - updateOrganization, - deleteOrganization, - updateSubscription, - getCurrentOrganizationSettings, -} from '@/app/actions/services/pocketbase/organization/core' - -// Membership functions -export { - addUserToOrganization, - removeUserFromOrganization, - getOrganizationUsers, -} from '@/app/actions/services/pocketbase/organization/membership' - -// Security utilities -export { isCurrentUserOrgAdmin } from '@/app/actions/services/pocketbase/organization/security' - -// Webhook handlers -export { - handleWebhookCreated, - handleWebhookUpdated, - handleWebhookDeleted, - handleMembershipWebhookCreated, - handleMembershipWebhookUpdated, - handleMembershipWebhookDeleted, -} from '@/app/actions/services/pocketbase/organization/webhook-handlers' diff --git a/src/app/actions/services/pocketbase/organization/internal.ts b/src/app/actions/services/pocketbase/organization/internal.ts deleted file mode 100644 index f046420..0000000 --- a/src/app/actions/services/pocketbase/organization/internal.ts +++ /dev/null @@ -1,134 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { Organization } from '@/types/types_pocketbase' - -/** - * Internal methods for organization management - * These methods have no security checks and should only be called - * from secured public API methods - */ - -/** - * Internal: Create organization without security checks - * @param data Organization data - */ -export async function _createOrganization( - data: Partial -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - console.info('Creating new Organization with data:', data) - - // Make sure clerkId is included - if (!data.clerkId) { - throw new Error('clerkId is required when creating a new Organization') - } - - const newOrg = await pb.collection('Organization').create(data) - console.info('New Organization created:', newOrg) - - // todo : fix types - return newOrg - } catch (error) { - console.error('Error creating organization:', error) - throw error - } -} - -/** - * Internal: Update organization without security checks - * @param id Organization ID - * @param data Updated organization data - */ -export async function _updateOrganization( - id: string, - data: Partial -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - console.info(`Updating Organization ${id} with data:`, data) - - const updatedOrg = await pb.collection('Organization').update(id, data) - console.info('Organization updated:', updatedOrg) - - // todo : fix types - return updatedOrg - } catch (error) { - console.error('Error updating organization:', error) - throw error - } -} - -/** - * Internal: Delete organization without security checks - * @param id Organization ID - */ -export async function _deleteOrganization(id: string): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('organizations').delete(id) - return true - } catch (error) { - handlePocketBaseError(error, 'OrganizationService._deleteOrganization') - return false - } -} - -/** - * Gets an organization by Clerk ID - * @param {string} clerkId - Clerk organization ID - * @returns {Promise} Organization record or null if not found - */ -export async function getOrganizationByClerkId( - clerkId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - console.info(`Searching for Organization with clerkId: ${clerkId}`) - - try { - const org = await pb - .collection('Organization') - .getFirstListItem(`clerkId="${clerkId}"`) - - console.info('Organization found:', org) - - // todo : fix types - return org as Organization - } catch (error) { - // Check if this is a "not found" error - if ( - error instanceof Error && - (error.message.includes('404') || error.message.includes('not found')) - ) { - console.info(`No organization found with clerkId: ${clerkId}`) - return null - } - // Otherwise rethrow the error - throw error - } - } catch (error) { - console.error('Error fetching organization by clerk ID:', error) - return null - } -} diff --git a/src/app/actions/services/pocketbase/organization/membership.ts b/src/app/actions/services/pocketbase/organization/membership.ts deleted file mode 100644 index 8e58826..0000000 --- a/src/app/actions/services/pocketbase/organization/membership.ts +++ /dev/null @@ -1,188 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { validateOrganizationAccess } from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { AppUser } from '@/types/types_pocketbase' - -/** - * Membership management functions for organizations - */ - -/** - * Internal: Add user to organization without security checks - * @param userId User ID - * @param organizationId Organization ID - */ -export async function _addUserToOrganization( - userId: string, - organizationId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Get the user - const user = await pb.collection('users').getOne(userId, { - expand: 'organizations', - }) - - // Get current organizations - let currentOrgs = user.organizations || [] - if (typeof currentOrgs === 'string') { - currentOrgs = [currentOrgs] - } - - // Check if user is already in organization - if (!currentOrgs.includes(organizationId)) { - // Add organization to user's organizations list - currentOrgs.push(organizationId) - } - - // Update user with new organizations list - return await pb.collection('users').update(userId, { - organizations: currentOrgs, - }) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._addUserToOrganization' - ) - } -} - -/** - * Internal: Remove user from organization without security checks - * @param userId User ID - * @param organizationId Organization ID - */ -export async function _removeUserFromOrganization( - userId: string, - organizationId: string -): Promise { - try { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Get the user - const user = await pb.collection('users').getOne(userId, { - expand: 'organizations', - }) - - // Get current organizations - let currentOrgs = user.organizations || [] - if (typeof currentOrgs === 'string') { - currentOrgs = [currentOrgs] - } - - // Remove organization from user's organizations list - const updatedOrgs = currentOrgs.filter( - (orgId: string) => orgId !== organizationId - ) - - // Update user with new organizations list - return await pb.collection('users').update(userId, { - organizations: updatedOrgs, - }) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService._removeUserFromOrganization' - ) - } -} - -/** - * Add a user to an organization - */ -export async function addUserToOrganization( - userId: string, - organizationId: string, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for member management - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - } - - return await _addUserToOrganization(userId, organizationId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.addUserToOrganization' - ) - } -} - -/** - * Remove a user from an organization - */ -export async function removeUserFromOrganization( - userId: string, - organizationId: string, - elevated = false -): Promise { - try { - if (!elevated) { - // Security check - requires ADMIN permission for member management - await validateOrganizationAccess(organizationId, PermissionLevel.ADMIN) - } - - return await _removeUserFromOrganization(userId, organizationId) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.removeUserFromOrganization' - ) - } -} - -/** - * Get all users in an organization - */ -export async function getOrganizationUsers( - organizationId: string -): Promise { - try { - // Security check - validates user has access to this organization - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Query users with this organization in their organizations list - const result = await pb.collection('users').getList(1, 100, { - filter: `organizations ~ "${organizationId}"`, - }) - - // todo : fix types - return result.items - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationUsers' - ) - } -} diff --git a/src/app/actions/services/pocketbase/organization/security.ts b/src/app/actions/services/pocketbase/organization/security.ts deleted file mode 100644 index 0fabfd9..0000000 --- a/src/app/actions/services/pocketbase/organization/security.ts +++ /dev/null @@ -1,39 +0,0 @@ -'use server' - -import { getUserOrganizations } from '@/app/actions/services/pocketbase/organization/core' -import { validateCurrentUser } from '@/app/actions/services/pocketbase/securityUtils' -import { SecurityError } from '@/app/actions/services/securyUtilsTools' - -/** - * Organization-specific security functions - */ - -/** - * Check if current user is organization admin for a specific organization - */ -export async function isCurrentUserOrgAdmin( - organizationId: string -): Promise { - try { - // Get current user - const user = await validateCurrentUser() - - // Check if user has admin role - const isAdmin = user.isAdmin || user.role === 'admin' - - if (!isAdmin) { - return false - } - - // Verify they belong to this organization - const userOrgs = await getUserOrganizations() - const belongsToOrg = userOrgs.some(org => org.id === organizationId) - - return isAdmin && belongsToOrg - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return false - } -} diff --git a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts b/src/app/actions/services/pocketbase/organization/webhook-handlers.ts deleted file mode 100644 index 68ee55a..0000000 --- a/src/app/actions/services/pocketbase/organization/webhook-handlers.ts +++ /dev/null @@ -1,318 +0,0 @@ -'use server' - -import * as appUserService from '@/app/actions/services/pocketbase/app-user' -import { - createOrganization, - updateOrganization, - deleteOrganization, -} from '@/app/actions/services/pocketbase/organization/core' -import { getByClerkId } from '@/app/actions/services/pocketbase/organization/internal' -import { - addUserToOrganization, - removeUserFromOrganization, -} from '@/app/actions/services/pocketbase/organization/membership' -import { - ClerkOrganizationWebhookData, - ClerkMembershipWebhookData, - WebhookProcessingResult, -} from '@/types/webhooks' - -/** - * Webhook handlers for organization events from Clerk - */ - -/** - * Handles a webhook event for organization creation - * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookCreated( - data: ClerkOrganizationWebhookData, - elevated = true -): Promise { - try { - // Check if already exists - const existing = await getByClerkId(data.id) - if (existing) { - return { - message: `Organization ${data.id} already exists`, - success: true, - } - } - - // Create new organization - await createOrganization( - { - clerkId: data.id, - name: data.name, - settings: { - imageUrl: data.image_url || null, - logoUrl: data.logo_url || null, - slug: data.slug || null, - }, - }, - elevated - ) - - return { - message: `Created organization ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process organization creation webhook:', error) - return { - message: `Failed to process organization creation: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for organization update - * @param {ClerkOrganizationWebhookData} data - Organization data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookUpdated( - data: ClerkOrganizationWebhookData, - elevated = true -): Promise { - try { - // Find existing organization - const existing = await getByClerkId(data.id) - if (!existing) { - return { - message: `Organization ${data.id} not found`, - success: false, - } - } - - // Update organization - await updateOrganization( - existing.id, - { - name: data.name, - settings: { - ...existing.settings, - imageUrl: data.image_url || existing.settings?.imageUrl || null, - logoUrl: data.logo_url || existing.settings?.logoUrl || null, - slug: data.slug || existing.settings?.slug || null, - }, - }, - elevated - ) - - return { - message: `Updated organization ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process organization update webhook:', error) - return { - message: `Failed to process organization update: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles a webhook event for organization deletion - * @param {ClerkOrganizationWebhookData} data - Organization deletion data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleWebhookDeleted( - data: ClerkOrganizationWebhookData, - elevated = true -): Promise { - try { - // Find existing organization - const existing = await getByClerkId(data.id) - if (!existing) { - return { - message: `Organization ${data.id} already deleted or not found`, - success: true, - } - } - - // Delete organization - await deleteOrganization(existing.id, elevated) - - return { - message: `Deleted organization ${data.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process organization deletion webhook:', error) - return { - message: `Failed to process organization deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles membership creation from webhook - * @param {ClerkMembershipWebhookData} data - Membership data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleMembershipWebhookCreated( - data: ClerkMembershipWebhookData, - elevated = true -): Promise { - try { - // Get organization and user - const organization = await getByClerkId(data.organization.id) - if (!organization) { - return { - message: `Organization with Clerk ID ${data.organization.id} not found`, - success: false, - } - } - - const user = await appUserService.getByClerkId( - data.public_user_data.user_id - ) - if (!user) { - return { - message: `User with Clerk ID ${data.public_user_data.user_id} not found`, - success: false, - } - } - - // Add user to organization - await addUserToOrganization(user.id, organization.id, elevated) - - // Update user role if needed - if (data.role === 'admin') { - await appUserService.updateAppUser( - user.id, - { - role: 'admin', - }, - elevated - ) - } - - return { - message: `Added user ${user.id} to organization ${organization.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process membership creation webhook:', error) - return { - message: `Failed to process membership creation: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles membership update from webhook - * @param {ClerkMembershipWebhookData} data - Membership data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleMembershipWebhookUpdated( - data: ClerkMembershipWebhookData, - elevated = true -): Promise { - try { - // Get organization and user - const organization = await getByClerkId(data.organization.id) - if (!organization) { - return { - message: `Organization with Clerk ID ${data.organization.id} not found`, - success: false, - } - } - - const user = await appUserService.getByClerkId( - data.public_user_data.user_id - ) - if (!user) { - return { - message: `User with Clerk ID ${data.public_user_data.user_id} not found`, - success: false, - } - } - - // Update user role if needed - if (data.role === 'admin') { - await appUserService.updateAppUser( - user.id, - { - role: 'admin', - }, - elevated - ) - } else if (data.role === 'member') { - await appUserService.updateAppUser( - user.id, - { - role: 'member', - }, - elevated - ) - } - - return { - message: `Updated user ${user.id} role in organization ${organization.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process membership update webhook:', error) - return { - message: `Failed to process membership update: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} - -/** - * Handles membership deletion from webhook - * @param {ClerkMembershipWebhookData} data - Membership data from Clerk - * @param {boolean} elevated - Whether operation has elevated permissions - * @returns {Promise} Processing result - */ -export async function handleMembershipWebhookDeleted( - data: ClerkMembershipWebhookData, - elevated = true -): Promise { - try { - // Get organization and user - const organization = await getByClerkId(data.organization.id) - if (!organization) { - return { - message: `Organization with Clerk ID ${data.organization.id} not found`, - success: true, - } - } - - const user = await appUserService.getByClerkId( - data.public_user_data.user_id - ) - if (!user) { - return { - message: `User with Clerk ID ${data.public_user_data.user_id} not found`, - success: true, - } - } - - // Remove user from organization - await removeUserFromOrganization(user.id, organization.id, elevated) - - return { - message: `Removed user ${user.id} from organization ${organization.id}`, - success: true, - } - } catch (error) { - console.error('Failed to process membership deletion webhook:', error) - return { - message: `Failed to process membership deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, - success: false, - } - } -} diff --git a/src/app/actions/services/pocketbase/organization_service.ts b/src/app/actions/services/pocketbase/organization_service.ts new file mode 100644 index 0000000..7674039 --- /dev/null +++ b/src/app/actions/services/pocketbase/organization_service.ts @@ -0,0 +1,134 @@ +'use server' + +import { z } from 'zod' + +import { BaseService, Collections, createServiceSchemas } from './api_client' +import { organizationSchema } from './api_client/schemas' +import { Organization } from './api_client/types' + +// Create schemas for organization operations +const { createSchema, updateSchema } = createServiceSchemas(organizationSchema) + +// Types based on the schemas +export type OrganizationCreateInput = z.infer +export type OrganizationUpdateInput = z.infer + +/** + * Service for Organization-related operations + */ +export class OrganizationService extends BaseService< + Organization, + OrganizationCreateInput, + OrganizationUpdateInput +> { + constructor() { + super( + Collections.ORGANIZATIONS, + organizationSchema, + createSchema, + updateSchema + ) + } + + /** + * Find an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @returns The organization or null if not found + */ + async findByClerkId(clerkId: string): Promise { + try { + const result = await this.getList({ + filter: `clerkId = "${clerkId}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding organization by clerkId:', error) + return null + } + } + + /** + * Check if an organization with the given Clerk ID exists + * + * @param clerkId - The Clerk organization ID + * @returns True if the organization exists + */ + async existsByClerkId(clerkId: string): Promise { + try { + const count = await this.getCount(`clerkId = "${clerkId}"`) + return count > 0 + } catch (error) { + console.error('Error checking if organization exists by clerkId:', error) + return false + } + } + + /** + * Create or update an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @param data - The organization data + * @returns The created or updated organization + */ + async createOrUpdateByClerkId( + clerkId: string, + data: Omit + ): Promise { + const existing = await this.findByClerkId(clerkId) + + if (existing) { + return this.update(existing.id, { + ...data, + clerkId, + }) + } + + return this.create({ + ...data, + clerkId, + }) + } +} + +// Singleton instance +let organizationServiceInstance: OrganizationService | null = null + +/** + * Get the OrganizationService instance + * + * @returns The OrganizationService instance + */ +export function getOrganizationService(): OrganizationService { + if (!organizationServiceInstance) { + organizationServiceInstance = new OrganizationService() + } + return organizationServiceInstance +} + +/** + * Find an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @returns The organization or null if not found + */ +export async function findOrganizationByClerkId( + clerkId: string +): Promise { + return getOrganizationService().findByClerkId(clerkId) +} + +/** + * Create or update an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @param data - The organization data + * @returns The created or updated organization + */ +export async function createOrUpdateOrganizationByClerkId( + clerkId: string, + data: Omit +): Promise { + return getOrganizationService().createOrUpdateByClerkId(clerkId, data) +} diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts deleted file mode 100644 index da7153a..0000000 --- a/src/app/actions/services/pocketbase/projectService.ts +++ /dev/null @@ -1,306 +0,0 @@ -'use server' - -import { - getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' -import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, -} from '@/app/actions/services/pocketbase/securityUtils' -import { - PermissionLevel, - ResourceType, - SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' - -/** - * Get a single project by ID with security validation - */ -export async function getProject(id: string): Promise { - try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.PROJECT, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('projects').getOne(id) - } catch (error) { - if (error instanceof SecurityError) { - throw error // Re-throw security errors - } - return handlePocketBaseError(error, 'ProjectService.getProject') - } -} - -/** - * Get projects list with pagination and security checks - */ -export async function getProjectsList( - organizationId: string, - options: ListOptions = {} -): Promise> { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const { - filter: additionalFilter, - page = 1, - perPage = 30, - ...rest - } = options - - // Apply organization filter to ensure data isolation - const filter = await createOrganizationFilter( - organizationId, - additionalFilter - ) - - return await pb.collection('projects').getList(page, perPage, { - ...rest, - filter, - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.getProjectsList') - } -} - -/** - * Get all projects for an organization with security checks - */ -export async function getOrganizationProjects( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter - fixed field name - const filter = `organizationId="${organizationId}"` - - return await pb.collection('projects').getFullList({ - filter, - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError( - error, - 'ProjectService.getOrganizationProjects' - ) - } -} - -/** - * Get active projects with security checks - * (current date is between startDate and endDate or endDate is not set) - */ -export async function getActiveProjects( - organizationId: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - - // Fixed field name in filter - return await pb.collection('projects').getFullList({ - filter: pb.filter( - 'organizationId = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', - { now, orgId: organizationId } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.getActiveProjects') - } -} - -/** - * Create a new project with security checks - */ -export async function createProject( - organizationId: string, - data: Pick< - Partial, - 'name' | 'address' | 'notes' | 'startDate' | 'endDate' - > -): Promise { - try { - // Security check - requires WRITE permission - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Ensure organization ID is set correctly - fixed field name - return await pb.collection('projects').create({ - ...data, - organizationId, // Force the correct organization ID - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.createProject') - } -} - -/** - * Update a project with security checks - */ -export async function updateProject( - id: string, - data: Pick< - Partial, - 'name' | 'address' | 'notes' | 'startDate' | 'endDate' - > -): Promise { - try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.PROJECT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Never allow changing the organization - const sanitizedData = { ...data } - // Fixed 'any' type and field name - delete (sanitizedData as Record).organizationId - - return await pb.collection('projects').update(id, sanitizedData) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.updateProject') - } -} - -/** - * Delete a project with security checks - */ -export async function deleteProject(id: string): Promise { - try { - // Security check - requires ADMIN permission for deletion - await validateResourceAccess( - ResourceType.PROJECT, - id, - PermissionLevel.ADMIN - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('projects').delete(id) - return true - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.deleteProject') - } -} - -/** - * Get project count for an organization with security checks - */ -export async function getProjectCount(organizationId: string): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name - const result = await pb.collection('projects').getList(1, 1, { - filter: `organizationId=${organizationId}`, - skipTotal: false, - }) - - return result.totalItems - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.getProjectCount') - } -} - -/** - * Search projects by name or address with security checks - */ -export async function searchProjects( - organizationId: string, - query: string -): Promise { - try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Fixed field name in filter - return await pb.collection('projects').getFullList({ - filter: pb.filter( - 'organizationId = {:orgId} && (name ~ {:query} || address ~ {:query})', - { - orgId: organizationId, - query, - } - ), - sort: 'name', - }) - } catch (error) { - if (error instanceof SecurityError) { - throw error - } - return handlePocketBaseError(error, 'ProjectService.searchProjects') - } -} diff --git a/src/app/actions/services/pocketbase/secured/equipment_service.ts b/src/app/actions/services/pocketbase/secured/equipment_service.ts new file mode 100644 index 0000000..619a972 --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/equipment_service.ts @@ -0,0 +1,211 @@ +'use server' + +import { Equipment } from '@/app/actions/services/pocketbase/api_client/types' +import { + EquipmentCreateInput, + EquipmentUpdateInput, + getEquipmentService, +} from '@/app/actions/services/pocketbase/equipment_service' +import { + SecurityContext, + SecurityError, + checkResourcePermission, + withSecurity, +} from '@/app/actions/services/pocketbase/secured/security_middleware' + +/** + * Get equipment by ID with security checks + */ +export const getEquipmentById = withSecurity( + async (id: string, context: SecurityContext): Promise => { + // Get the equipment + const equipmentService = getEquipmentService() + const equipment = await equipmentService.getById(id) + + // Check permission + if (!checkResourcePermission(equipment.organization, context)) { + throw new SecurityError( + 'Forbidden: You do not have access to this equipment', + 403 + ) + } + + return equipment + } +) + +/** + * List equipment with security context + */ +export const listOrganizationEquipment = withSecurity( + async ( + params: { + searchTerm?: string + page?: number + perPage?: number + sort?: string + }, + context: SecurityContext + ): Promise<{ + items: Equipment[] + totalItems: number + totalPages: number + }> => { + const equipmentService = getEquipmentService() + + // Apply the organization filter automatically + const filter = params.searchTerm + ? `organization = "${context.orgPbId}" && (name ~ "${params.searchTerm}" || tags ~ "${params.searchTerm}" || notes ~ "${params.searchTerm}")` + : `organization = "${context.orgPbId}"` + + // Get the equipment list + const result = await equipmentService.getList({ + filter, + page: params.page, + perPage: params.perPage, + sort: params.sort, + }) + + return { + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + } + } +) + +/** + * Create new equipment with security context + */ +export const createEquipment = withSecurity( + async ( + data: Omit, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // Always set the organization to the current user's organization + const equipmentData: EquipmentCreateInput = { + ...data, + organization: context.orgPbId, + } + + // Generate QR/NFC code if not provided + if (!equipmentData.qrNfcCode) { + equipmentData.qrNfcCode = await equipmentService.generateUniqueCode() + } + + return equipmentService.create(equipmentData) + }, + { revalidatePaths: ['/app/equipment'] } +) + +/** + * Update equipment with security checks + */ +export const updateEquipment = withSecurity( + async ( + params: { id: string; data: EquipmentUpdateInput }, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // Get the equipment first to check permissions + const existingEquipment = await equipmentService.getById(params.id) + + // Check permission + if (!checkResourcePermission(existingEquipment.organization, context)) { + throw new SecurityError( + 'Forbidden: You do not have access to this equipment', + 403 + ) + } + + // Prevent changing the organization + const updateData = params.data as Record + if ( + typeof updateData === 'object' && + updateData !== null && + 'organization' in updateData && + updateData.organization && + updateData.organization !== context.orgPbId + ) { + throw new SecurityError( + 'Forbidden: Cannot change equipment organization', + 403 + ) + } + + return equipmentService.update(params.id, params.data) + }, + { revalidatePaths: ['/app/equipment'] } +) + +/** + * Delete equipment with security checks + */ +export const deleteEquipment = withSecurity( + async (id: string, context: SecurityContext): Promise => { + const equipmentService = getEquipmentService() + + // Get the equipment first to check permissions + const existingEquipment = await equipmentService.getById(id) + + // Check permission (require admin for deletion) + if ( + !checkResourcePermission(existingEquipment.organization, context, true) + ) { + throw new SecurityError( + 'Forbidden: Only administrators can delete equipment', + 403 + ) + } + + return equipmentService.delete(id) + }, + { + requireAdmin: true, + revalidatePaths: ['/app/equipment'], + } +) + +/** + * Search equipment with security context + */ +export const searchEquipment = withSecurity( + async ( + searchTerm: string, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // The search is already scoped to the organization + return equipmentService.search(context.orgPbId, searchTerm) + } +) + +/** + * Find equipment by QR/NFC code with security context + */ +export const findEquipmentByQrNfcCode = withSecurity( + async ( + qrNfcCode: string, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + const equipment = await equipmentService.findByQrNfcCode(qrNfcCode) + + // If no equipment found, return null + if (!equipment) { + return null + } + + // Check permission - return null if no access instead of error + if (!checkResourcePermission(equipment.organization, context)) { + return null + } + + return equipment + } +) diff --git a/src/app/actions/services/pocketbase/secured/index.ts b/src/app/actions/services/pocketbase/secured/index.ts new file mode 100644 index 0000000..660fea3 --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/index.ts @@ -0,0 +1,14 @@ +'use server' + +/** + * Secured PocketBase services with security middleware + * These services enforce permissions and access controls + */ + +// Security middleware +export * from '@/app/actions/services/pocketbase/secured/security_middleware' + +// Secured services +export * from '@/app/actions/services/pocketbase/secured/equipment_service' + +// Export more secured services here as they are created diff --git a/src/app/actions/services/pocketbase/secured/security_middleware.ts b/src/app/actions/services/pocketbase/secured/security_middleware.ts new file mode 100644 index 0000000..38b2bfd --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/security_middleware.ts @@ -0,0 +1,147 @@ +'use server' + +import { PocketBaseApiError } from '@/app/actions/services/pocketbase/api_client/client' +import { findUserByClerkId } from '@/app/actions/services/pocketbase/app_user_service' +import { findOrganizationByClerkId } from '@/app/actions/services/pocketbase/organization_service' +import { auth } from '@clerk/nextjs/server' +import { revalidatePath } from 'next/cache' + +/** + * Security middleware error class + */ +export class SecurityError extends Error { + statusCode: number + + constructor(message: string, statusCode = 401) { + super(message) + this.name = 'SecurityError' + this.statusCode = statusCode + } +} + +/** + * Type for the security context provided to secured actions + */ +export interface SecurityContext { + userId: string + orgId: string + orgRole: string + userPbId: string + orgPbId: string + isAdmin: boolean +} + +/** + * Type for a handler function that requires security context + */ +export type SecuredHandler = ( + params: TParams, + context: SecurityContext +) => Promise + +/** + * Higher-order function that wraps server actions with security checks + * + * @param handler - The server action handler function + * @param options - Security options + * @returns A new handler function with security checks + */ +export function withSecurity( + handler: SecuredHandler, + options: { + revalidatePaths?: string[] + requireAdmin?: boolean + } = {} +) { + return async (params: TParams): Promise => { + try { + // Get auth info from Clerk (auth() is async in Next.js 14) + const authData = await auth() + + // Check if user is authenticated + if (!authData.userId) { + throw new SecurityError('Unauthorized: User not authenticated') + } + + // Check if user has selected an organization + if (!authData.orgId) { + throw new SecurityError('Unauthorized: No organization selected') + } + + // Check admin requirement if needed + if (options.requireAdmin && authData.orgRole !== 'admin') { + throw new SecurityError('Forbidden: Admin access required', 403) + } + + // Get the PocketBase IDs for the user and organization + const userRecord = await findUserByClerkId(authData.userId) + if (!userRecord) { + throw new SecurityError('User not found in database') + } + + const orgRecord = await findOrganizationByClerkId(authData.orgId) + if (!orgRecord) { + throw new SecurityError('Organization not found in database') + } + + // Create security context + const securityContext: SecurityContext = { + isAdmin: authData.orgRole === 'admin' || userRecord.isAdmin, + orgId: authData.orgId, + orgPbId: orgRecord.id, + orgRole: authData.orgRole || 'member', + userId: authData.userId, + userPbId: userRecord.id, + } + + // Call the handler with security context + const result = await handler(params, securityContext) + + // Revalidate paths if specified + if (options.revalidatePaths) { + for (const path of options.revalidatePaths) { + revalidatePath(path) + } + } + + return result + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + + if (error instanceof PocketBaseApiError) { + throw error + } + + console.error('Error in secured handler:', error) + throw new SecurityError('An unexpected error occurred', 500) + } + } +} + +/** + * Check if the current user has permission to access a resource + * + * @param resourceOrgId - The organization ID associated with the resource + * @param context - The security context + * @param requireAdmin - Whether admin access is required + * @returns True if the user has permission + */ +export function checkResourcePermission( + resourceOrgId: string, + context: SecurityContext, + requireAdmin = false +): boolean { + // Check if the resource belongs to the user's organization + if (resourceOrgId !== context.orgPbId) { + return false + } + + // Check admin requirement if needed + if (requireAdmin && !context.isAdmin) { + return false + } + + return true +} diff --git a/src/app/actions/services/pocketbase/securityUtils.ts b/src/app/actions/services/pocketbase/securityUtils.ts index afaefdc..03eb457 100644 --- a/src/app/actions/services/pocketbase/securityUtils.ts +++ b/src/app/actions/services/pocketbase/securityUtils.ts @@ -1,6 +1,6 @@ 'use server' -import { getPocketBase } from '@/app/actions/services/pocketbase/baseService' +import { getPocketBase } from '@/app/actions/services/pocketbase/api_client/client' import { SecurityError, PermissionLevel, @@ -22,7 +22,7 @@ export async function validateCurrentUser(userId?: string): Promise { throw new SecurityError('Unauthenticated access') } - const pb = await getPocketBase() + const pb = getPocketBase() if (!pb) { throw new SecurityError('Database connection error') } @@ -98,7 +98,7 @@ export async function validateResourceAccess( resourceId: string, permission: PermissionLevel = PermissionLevel.READ ): Promise<{ user: AppUser; organizationId: string }> { - const pb = await getPocketBase() + const pb = getPocketBase() if (!pb) { throw new SecurityError('Database connection error') } diff --git a/src/app/api/webhook/clerk/organization-membership/route.ts b/src/app/api/webhook/clerk/organization-membership/route.ts index d51d53e..bb651e8 100644 --- a/src/app/api/webhook/clerk/organization-membership/route.ts +++ b/src/app/api/webhook/clerk/organization-membership/route.ts @@ -1,88 +1,63 @@ +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { WebhookEvent } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' /** * Handles webhook events from Clerk related to organization memberships */ export async function POST(req: NextRequest) { - // Verify webhook signature - const isValid = await verifyClerkWebhook( - req, - process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION - ) - - if (!isValid) { - console.error('Invalid webhook signature for organization membership event') - return new NextResponse('Invalid signature', { status: 401 }) - } + console.info('Received Clerk organization membership webhook request') try { - // Get the request body - const body = await req.json() - const { data, type } = body + // Parse the request body + const body = (await req.json()) as WebhookEvent + + // Get the Svix headers for verification + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') + + // Validate that we have all required headers + if (!svixId || !svixTimestamp || !svixSignature) { + console.error('Missing required Svix headers') + return new NextResponse('Unauthorized: Missing verification headers', { + status: 401, + }) + } - console.log(`Processing organization membership webhook: ${type}`) + // Verify the webhook signature + const isValid = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION + ) - // Handle different organization membership events - switch (type) { - case 'organizationMembership.created': - await handleMembershipCreated(data) - break - case 'organizationMembership.updated': - await handleMembershipUpdated(data) - break - case 'organizationMembership.deleted': - await handleMembershipDeleted(data) - break - default: - console.log(`Unhandled organization membership event type: ${type}`) + if (!isValid) { + console.error( + 'Invalid webhook signature for organization membership event' + ) + return new NextResponse('Unauthorized: Invalid signature', { + status: 401, + }) } - return NextResponse.json({ success: true }) + console.info('Webhook verified successfully:', { type: body.type }) + + // Process the webhook using our central handler + const result = await processWebhookEvent(body) + return NextResponse.json(result) } catch (error) { - console.error('Error processing organization membership webhook:', error) + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + console.error('Error processing webhook:', error) + return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Failed to process webhook', + message: errorMessage, + success: false, + }, { status: 500 } ) } } - -/** - * Handles organization membership creation event - */ -async function handleMembershipCreated(data: any) { - const { id: membershipId, organization, public_user_data, role } = data - - console.log( - `Membership created: User ${public_user_data.user_id} joined organization ${organization.id} as ${role}` - ) - - // TODO: Store the organization membership in your database -} - -/** - * Handles organization membership update event - */ -async function handleMembershipUpdated(data: any) { - const { id: membershipId, organization, public_user_data, role } = data - - console.log( - `Membership updated: User ${public_user_data.user_id} in organization ${organization.id}, role: ${role}` - ) - - // TODO: Update the organization membership in your database -} - -/** - * Handles organization membership deletion event - */ -async function handleMembershipDeleted(data: any) { - const { id: membershipId, organization, public_user_data } = data - - console.log( - `Membership deleted: User ${public_user_data.user_id} removed from organization ${organization.id}` - ) - - // TODO: Remove or mark as deleted the organization membership in your database -} diff --git a/src/app/api/webhook/clerk/organization/route.ts b/src/app/api/webhook/clerk/organization/route.ts index fbc79ea..6ace53c 100644 --- a/src/app/api/webhook/clerk/organization/route.ts +++ b/src/app/api/webhook/clerk/organization/route.ts @@ -1,146 +1,61 @@ -import { createOrganization } from '@/app/actions/services/pocketbase/organizationService' +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { WebhookEvent } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' /** * Handles webhook events from Clerk related to organizations */ export async function POST(req: NextRequest) { - // Verify webhook signature - const isValid = await verifyClerkWebhook( - req, - process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION - ) - - if (!isValid) { - console.error('Invalid webhook signature for organization event') - return new NextResponse('Invalid signature', { status: 401 }) - } + console.info('Received Clerk organization webhook request') try { - // Get the request body (clone request since body was consumed by verification) - const clone = req.clone() - const body = await clone.json() - const { data, type } = body - - console.log(`Processing organization webhook: ${type}`) - - // Handle different organization events - switch (type) { - case 'organization.created': - await handleOrganizationCreated(data) - break - case 'organization.updated': - await handleOrganizationUpdated(data) - break - case 'organization.deleted': - await handleOrganizationDeleted(data) - break - default: - console.log(`Unhandled organization event type: ${type}`) + // Parse the request body + const body = (await req.json()) as WebhookEvent + + // Get the Svix headers for verification + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') + + // Validate that we have all required headers + if (!svixId || !svixTimestamp || !svixSignature) { + console.error('Missing required Svix headers') + return new NextResponse('Unauthorized: Missing verification headers', { + status: 401, + }) } - return NextResponse.json({ success: true }) - } catch (error) { - console.error('Error processing organization webhook:', error) - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } + // Verify the webhook signature + const isValid = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION ) - } -} - -/** - * Handles organization creation event - */ -async function handleOrganizationCreated(data: any) { - const { id: clerkId, logo_url, name, public_metadata, slug } = data - - console.log(`Organization created: ${clerkId} (${name})`) - - try { - await createOrganization({ - clerkId, - logoUrl: logo_url, - name, - publicMetadata: public_metadata || {}, - slug: slug || name.toLowerCase().replace(/\s+/g, '-'), - }) - - console.log(`Successfully created organization in PocketBase: ${clerkId}`) - } catch (error) { - console.error( - `Failed to create organization in PocketBase: ${clerkId}`, - error - ) - throw error - } -} - -/** - * Handles organization update event - */ -async function handleOrganizationUpdated(data: any) { - const { id: clerkId, image_url, logo_url, name, public_metadata, slug } = data - - console.log(`Organization updated: ${clerkId} (${name})`) - try { - const organization = await organizationService.findByClerkId(clerkId) - - if (!organization) { - console.error( - `Organization not found in PocketBase for clerkId: ${clerkId}` - ) - return + if (!isValid) { + console.error('Invalid webhook signature for organization event') + return new NextResponse('Unauthorized: Invalid signature', { + status: 401, + }) } - await organizationService.updateOrganization(organization.id, { - imageUrl: image_url, - logoUrl: logo_url, - name, - publicMetadata: public_metadata || {}, - slug, - }) + console.info('Webhook verified successfully:', { type: body.type }) - console.log(`Successfully updated organization in PocketBase: ${clerkId}`) + // Process the webhook using our central handler + const result = await processWebhookEvent(body) + return NextResponse.json(result) } catch (error) { - console.error( - `Failed to update organization in PocketBase: ${clerkId}`, - error - ) - throw error - } -} - -/** - * Handles organization deletion event - */ -async function handleOrganizationDeleted(data: any) { - const { id: clerkId } = data + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + console.error('Error processing webhook:', error) - console.log(`Organization deleted: ${clerkId}`) - - try { - const organization = await organizationService.findByClerkId(clerkId) - - if (!organization) { - console.log( - `Organization not found in PocketBase for clerkId: ${clerkId}` - ) - return - } - - await organizationService.softDeleteOrganization(organization.id) - - console.log( - `Successfully marked organization as deleted in PocketBase: ${clerkId}` - ) - } catch (error) { - console.error( - `Failed to mark organization as deleted in PocketBase: ${clerkId}`, - error + return NextResponse.json( + { + error: 'Failed to process webhook', + message: errorMessage, + success: false, + }, + { status: 500 } ) - throw error } } diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index 837e73f..59d965b 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -1,31 +1,17 @@ -import { - handleWebhookCreated, - handleWebhookDeleted, - handleWebhookUpdated, -} from '@/app/actions/services/pocketbase/app-user/webhook-handlers' +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { WebhookEvent } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' -// Define types for Clerk webhook payload -interface ClerkWebhookData { - id: string - [key: string]: unknown -} - -interface ClerkWebhookEvent { - type: string - data: ClerkWebhookData -} - /** * Handles Clerk webhook requests for user-related events */ export async function POST(req: NextRequest) { - console.info('Received Clerk webhook request') + console.info('Received Clerk user webhook request') try { // Parse the request body - const body = (await req.json()) as ClerkWebhookEvent + const body = (await req.json()) as WebhookEvent // Get the Svix headers for verification const svixId = req.headers.get('svix-id') @@ -55,33 +41,8 @@ export async function POST(req: NextRequest) { console.info('Webhook verified successfully:', { type: body.type }) - // Process the webhook based on its type - let result - - switch (body.type) { - case 'user.created': - console.info('Processing user.created event') - result = await handleWebhookCreated(body.data) - break - - case 'user.updated': - console.info('Processing user.updated event') - result = await handleWebhookUpdated(body.data) - break - - case 'user.deleted': - console.info('Processing user.deleted event') - result = await handleWebhookDeleted(body.data) - break - - default: - console.info(`Ignoring unsupported webhook type: ${body.type}`) - return NextResponse.json({ - message: `Webhook type ${body.type} is not supported`, - success: false, - }) - } - + // Process the webhook using our central handler + const result = await processWebhookEvent(body) return NextResponse.json(result) } catch (error) { const errorMessage = diff --git a/src/types/types_pocketbase.ts b/src/types/types_pocketbase.ts deleted file mode 100644 index 0dd9c9f..0000000 --- a/src/types/types_pocketbase.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Common fields for all record models - */ -export interface BaseModel { - id: string - created: string - updated: string - collectionId: string - collectionName: string -} - -/** - * Organization model - */ -export interface Organization extends BaseModel { - name: string - email?: string - phone?: string - address?: string - settings?: Record - - // Clerk integration fields - clerkId: string - - // Stripe related fields - stripeCustomerId?: string - subscriptionId?: string - subscriptionStatus?: string - priceId?: string -} - -/** - * User model (auth collection) - */ -export interface AppUser { - id: string - email: string - emailVisibility: boolean - verified: boolean - name: string - avatar?: string - phone?: string - role?: string - isAdmin: boolean - lastLogin?: string - clerkId: string - organizations?: Organization[] - created: string - updated: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - metadata?: Record -} - -/** - * Equipment model - */ -export interface Equipment extends BaseModel { - organizationId: string // References Organization.id - name: string | null - qrNfcCode: string | null - tags: string[] - notes: string | null - acquisitionDate: string | null // ISO date string - parentEquipmentId: string | null // Self-reference - - // Expanded relations - expand?: { - organizationId?: Organization - parentEquipmentId?: Equipment - } -} - -/** - * Project model - */ -export interface Project extends BaseModel { - name: string | null - address: string | null - notes: string | null - startDate: string | null // ISO date string - endDate: string | null // ISO date string - organizationId: string // References Organization.id - - // Expanded relations - expand?: { - organizationId?: Organization - } -} - -/** - * Assignment model - */ -export interface Assignment extends BaseModel { - organizationId: string // References Organization.id - equipmentId: string // References Equipment.id - assignedToUserId: string | null // References User.id - assignedToProjectId: string | null // References Project.id - startDate: string | null // ISO date string - endDate: string | null // ISO date string - notes: string | null - - // Expanded relations - expand?: { - organizationId?: Organization - equipmentId?: Equipment - assignedToUserId?: AppUser - assignedToProjectId?: Project - } -} - -/** - * Images model (this will be used to store images for the blog etc) - */ -export interface Image extends BaseModel { - title: string | null - alt: string | null - caption: string | null - image: string | null - - // Expanded relations - expand?: Record -} - -/** - * Filter options for list operations - */ -export interface ListOptions { - filter?: string - sort?: string - expand?: string - fields?: string - skipTotal?: boolean - page?: number - perPage?: number - requestKey?: string | null -} - -/** - * Common result format for paginated lists - */ -export interface ListResult { - page: number - perPage: number - totalItems: number - totalPages: number - items: T[] -} diff --git a/src/types/webhooks.ts b/src/types/webhooks.ts deleted file mode 100644 index 81cbe3a..0000000 --- a/src/types/webhooks.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Types for webhook events and data - */ -import { WebhookEvent } from "@clerk/nextjs/server"; - -// Clerk Organization Webhook Data -export interface ClerkOrganizationWebhookData { - id: string; - name: string; - slug?: string; - logo_url?: string; - image_url?: string; - created_at?: number; - updated_at?: number; - created_by?: string; - public_metadata?: Record; - deleted?: boolean; -} - -// Clerk Organization Membership Webhook Data -export interface ClerkMembershipWebhookData { - id: string; - role: string; - created_at?: number; - updated_at?: number; - organization: { - id: string; - name: string; - slug?: string; - }; - public_user_data: { - user_id: string; - first_name?: string; - last_name?: string; - identifier: string; - image_url?: string; - profile_image_url?: string; - }; - deleted?: boolean; -} - -// Clerk User Webhook Data -export interface ClerkUserWebhookData { - id: string; - email_addresses?: Array<{ - email_address: string; - id: string; - }>; - first_name?: string; - last_name?: string; - profile_image_url?: string; - image_url?: string; - primary_email_address_id?: string; - deleted?: boolean; -} - -// Webhook Processing Result -export interface WebhookProcessingResult { - success: boolean; - message: string; -} - -// Webhook Handler Function Type -export type WebhookHandler = (event: WebhookEvent) => Promise; \ No newline at end of file From c4fa7a7386c0f91071cee6d2d31e879deb5c9852 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 13:48:11 +0200 Subject: [PATCH 47/73] feat(docs): update README and add project structure - Introduce QR/NFC Equipment Management System overview - Outline project structure with modular organization - Centralize types and validation schemas for better maintainability - Add service details for equipment-related operations - Include convenience functions for equipment management --- README.md | 54 ++++++++++++ .../services/pocketbase/equipment_service.ts | 57 +++++++++---- src/lib/tagsUtils.ts | 1 + src/lib/webhookUtils.ts | 2 +- src/schemas/pocketbase/base.ts | 82 +++++++++++++++++++ src/schemas/pocketbase/equipment.ts | 61 ++++++++++++++ src/schemas/pocketbase/index.ts | 15 ++++ src/types/pocketbase/base.ts | 38 +++++++++ src/types/pocketbase/collections.ts | 13 +++ src/types/pocketbase/equipment.ts | 35 ++++++++ src/types/pocketbase/index.ts | 18 ++++ 11 files changed, 359 insertions(+), 17 deletions(-) create mode 100644 src/schemas/pocketbase/base.ts create mode 100644 src/schemas/pocketbase/equipment.ts create mode 100644 src/schemas/pocketbase/index.ts create mode 100644 src/types/pocketbase/base.ts create mode 100644 src/types/pocketbase/collections.ts create mode 100644 src/types/pocketbase/equipment.ts create mode 100644 src/types/pocketbase/index.ts diff --git a/README.md b/README.md index 1be1175..87da603 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,57 @@ for example, we can have : - - - + +# QR/NFC Equipment Management System + +A SaaS platform for managing equipment inventory and tracking using QR codes and NFC technology. + +## Project Structure + +The project follows a modular structure with clear separation of concerns: + +### Types Organization + +All types are centralized in the `/types` directory to avoid duplication and maintain DRY principles: + +``` +/types + /pocketbase # PocketBase related types + /base.ts # Base types (BaseRecord, ListResult, etc.) + /collections.ts # Collection name constants + /equipment.ts # Equipment specific types + /index.ts # Re-exports for easier imports + # Other type categories can be added here +``` + +### Validation Schemas + +Zod validation schemas are centralized in the `/schemas` directory: + +``` +/schemas + /pocketbase # PocketBase related schemas + /base.ts # Base schemas and utility functions + /equipment.ts # Equipment specific schemas + /index.ts # Re-exports for easier imports + # Other schema categories can be added here +``` + +### Services + +Services use the centralized types and schemas: + +``` +/app/actions/services + /pocketbase + /api_client # Base API client + /equipment_service.ts # Equipment specific service + # Other services +``` + +This structure ensures: + +- No type duplication (DRY principle) +- Clear separation of concerns +- Easy maintenance and refactoring +- Type safety across the application diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts index a6653c2..a33c831 100644 --- a/src/app/actions/services/pocketbase/equipment_service.ts +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -1,23 +1,25 @@ 'use server' -import { z } from 'zod' - -import { BaseService, Collections, createServiceSchemas } from './api_client' -import { equipmentSchema } from './api_client/schemas' -import { Equipment } from './api_client/types' - -// Re-export Equipment type -export type { Equipment } +import { + equipmentCreateSchema, + equipmentSchema, + equipmentUpdateSchema, +} from '@/schemas/pocketbase' +import { + Collections, + Equipment, + EquipmentCreateInput, + EquipmentUpdateInput, +} from '@/types/pocketbase' -// Create schemas for equipment operations -const { createSchema, updateSchema } = createServiceSchemas(equipmentSchema) +import { BaseService } from './api_client' -// Types based on the schemas -export type EquipmentCreateInput = z.infer -export type EquipmentUpdateInput = z.infer +// Re-export types for convenience +export type { Equipment, EquipmentCreateInput, EquipmentUpdateInput } /** - * Service for Equipment-related operations + * Service for equipment-related operations + * Provides CRUD and search functionality for equipment records */ export class EquipmentService extends BaseService< Equipment, @@ -25,7 +27,13 @@ export class EquipmentService extends BaseService< EquipmentUpdateInput > { constructor() { - super(Collections.EQUIPMENT, equipmentSchema, createSchema, updateSchema) + // @eslint-disable-next-line @typescript-eslint/ban-ts-comment @ts-expect-error - Types are compatible but TypeScript cannot verify it + super( + Collections.EQUIPMENT, + equipmentSchema, + equipmentCreateSchema, + equipmentUpdateSchema + ) } /** @@ -100,8 +108,20 @@ export class EquipmentService extends BaseService< // Clean up search term for use in filter const cleanTerm = searchTerm.trim().replace(/"/g, '\\"') + // Construct the filter as a string, properly escaping quotation marks + const filter = + 'organization = "' + + organizationId + + '" && (name ~ "' + + cleanTerm + + '" || tags ~ "' + + cleanTerm + + '" || notes ~ "' + + cleanTerm + + '")' + const result = await this.getList({ - filter: `organization = "${organizationId}" && (name ~ "${cleanTerm}" || tags ~ "${cleanTerm}" || notes ~ "${cleanTerm}")`, + filter, }) return result.items @@ -146,6 +166,7 @@ let equipmentServiceInstance: EquipmentService | null = null /** * Get the EquipmentService instance + * Uses singleton pattern to ensure only one instance exists * * @returns The EquipmentService instance */ @@ -158,6 +179,7 @@ export function getEquipmentService(): EquipmentService { /** * Find equipment by QR/NFC code + * Convenience function that uses the EquipmentService * * @param qrNfcCode - The QR/NFC code * @returns The equipment or null if not found @@ -170,6 +192,7 @@ export async function findEquipmentByQrNfcCode( /** * Find all equipment for an organization + * Convenience function that uses the EquipmentService * * @param organizationId - The organization ID * @returns Array of equipment @@ -182,6 +205,7 @@ export async function findEquipmentByOrganization( /** * Search equipment by name, tags or notes + * Convenience function that uses the EquipmentService * * @param organizationId - The organization ID * @param searchTerm - Term to search for @@ -196,6 +220,7 @@ export async function searchEquipment( /** * Generate a new unique QR/NFC code + * Convenience function that uses the EquipmentService * * @returns A new unique code */ diff --git a/src/lib/tagsUtils.ts b/src/lib/tagsUtils.ts index 44a2f0e..61f3f9e 100644 --- a/src/lib/tagsUtils.ts +++ b/src/lib/tagsUtils.ts @@ -37,6 +37,7 @@ export function tagsFromStorage(tagsString: string | null): string[] { .map(tag => tag.trim()) .filter(Boolean) } + console.error('Error parsing tags:', error) return [] } } diff --git a/src/lib/webhookUtils.ts b/src/lib/webhookUtils.ts index 28c16b0..9ba27cb 100644 --- a/src/lib/webhookUtils.ts +++ b/src/lib/webhookUtils.ts @@ -7,7 +7,7 @@ import { Webhook } from 'svix' export async function verifyClerkWebhook( req: NextRequest, secret: string | undefined -): Promise<{ success: boolean; payload?: any }> { +): Promise<{ success: boolean; payload?: unknown }> { if (!secret) { console.error('Missing Clerk webhook secret') return { success: false } diff --git a/src/schemas/pocketbase/base.ts b/src/schemas/pocketbase/base.ts new file mode 100644 index 0000000..b23650c --- /dev/null +++ b/src/schemas/pocketbase/base.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' + +/** + * Base schema for all PocketBase records + * Contains validation for system fields present in all records + */ +export const baseRecordSchema = z.object({ + collectionId: z.string().optional(), + collectionName: z.string().optional(), + created: z.string().datetime(), + id: z.string(), + updated: z.string().datetime(), +}) + +/** + * List result schema (generic) + * Used for paginated results from PocketBase + */ +export const listResultSchema = (itemSchema: T) => + z.object({ + items: z.array(itemSchema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + +/** + * Query parameters schema + * Validates parameters for list operations + */ +export const queryParamsSchema = z.object({ + expand: z.string().optional(), + filter: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), + sort: z.string().optional(), +}) + +/** + * Create partial schema by making all properties optional + * Useful for update operations + */ +export function createPartialSchema( + schema: z.ZodType +): z.ZodType { + // Handle ZodObject specifically + if (schema instanceof z.ZodObject) { + return schema.partial() + } + + // For non-object schemas, we can't properly partial them + // Just return the original schema as a fallback + console.warn('Attempting to create partial schema for non-object type') + return schema as z.ZodType +} + +/** + * Create schemas for CRUD operations based on a base schema + */ +export function createServiceSchemas(baseSchema: z.ZodType) { + // For create operations, strip system fields + const createSchema = + baseSchema instanceof z.ZodObject + ? baseSchema.omit({ + collectionId: true, + collectionName: true, + created: true, + id: true, + updated: true, + }) + : baseSchema + + // For update operations, make all properties optional + const updateSchema = createPartialSchema(createSchema as z.ZodType) + + return { + baseSchema, + createSchema, + updateSchema, + } +} diff --git a/src/schemas/pocketbase/equipment.ts b/src/schemas/pocketbase/equipment.ts new file mode 100644 index 0000000..893008f --- /dev/null +++ b/src/schemas/pocketbase/equipment.ts @@ -0,0 +1,61 @@ +import type { + EquipmentCreateInput, + EquipmentUpdateInput, +} from '@/types/pocketbase' + +import { + baseRecordSchema, + createServiceSchemas, +} from '@/schemas/pocketbase/base' +import { z } from 'zod' + +/** + * Equipment schema for validation + * Defines validation rules for equipment records from PocketBase + */ +export const equipmentSchema = baseRecordSchema.extend({ + acquisitionDate: z.string(), + name: z.string(), + notes: z.string(), + organization: z.string(), + parentEquipment: z.string().optional(), + qrNfcCode: z.string(), + tags: z.string(), +}) + +/** + * Generated schemas for equipment CRUD operations + * We use the update schema but create a custom create schema + */ +const { updateSchema } = createServiceSchemas(equipmentSchema) + +/** + * Schema for equipment creation + * Use this for validating data when creating new equipment + */ +export const equipmentCreateSchema = z + .object({ + acquisitionDate: z.string().optional(), + name: z.string(), + notes: z.string().optional(), + organization: z.string(), + parentEquipment: z.string().optional(), + qrNfcCode: z.string(), + tags: z.string().optional(), + }) + .transform(data => { + // Ensure all string fields have default values + return { + ...data, + acquisitionDate: data.acquisitionDate || '', + notes: data.notes || '', + tags: data.tags || '', + } + }) as z.ZodType + +/** + * Schema for equipment updates + * Use this for validating data when updating equipment + */ +export const equipmentUpdateSchema = + updateSchema as z.ZodType diff --git a/src/schemas/pocketbase/index.ts b/src/schemas/pocketbase/index.ts new file mode 100644 index 0000000..9d675e3 --- /dev/null +++ b/src/schemas/pocketbase/index.ts @@ -0,0 +1,15 @@ +/** + * PocketBase schemas exports + * This file centralizes all PocketBase related schema exports for easier imports + */ + +// Base schemas and utilities +export * from '@/schemas/pocketbase/base' + +// Entity schemas +export * from '@/schemas/pocketbase/equipment' + +// Add other entity schemas as they are created: +// export * from './organization' +// export * from './app-user' +// etc. diff --git a/src/types/pocketbase/base.ts b/src/types/pocketbase/base.ts new file mode 100644 index 0000000..de8dbe6 --- /dev/null +++ b/src/types/pocketbase/base.ts @@ -0,0 +1,38 @@ +/** + * Base types for PocketBase data models + * These types represent the common schema structure across all collections + */ + +/** + * Base interface for all PocketBase records + * Contains system fields present in every PocketBase record + */ +export interface BaseRecord { + id: string + created: string + updated: string + collectionId?: string + collectionName?: string +} + +/** + * Generic list result interface for paginated PocketBase responses + */ +export interface ListResult { + page: number + perPage: number + totalItems: number + totalPages: number + items: T[] +} + +/** + * Generic query parameters for list operations + */ +export interface QueryParams { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} diff --git a/src/types/pocketbase/collections.ts b/src/types/pocketbase/collections.ts new file mode 100644 index 0000000..1c0af0f --- /dev/null +++ b/src/types/pocketbase/collections.ts @@ -0,0 +1,13 @@ +/** + * Collection names used throughout the application + * Centralizing these constants prevents typos and makes refactoring easier + */ +export enum Collections { + ACTIVITY_LOGS = 'activity_logs', + APP_USERS = 'app_users', + ASSIGNMENTS = 'assignments', + EQUIPMENT = 'equipment', + IMAGES = 'images', + ORGANIZATIONS = 'organizations', + PROJECTS = 'projects', +} diff --git a/src/types/pocketbase/equipment.ts b/src/types/pocketbase/equipment.ts new file mode 100644 index 0000000..cf51efa --- /dev/null +++ b/src/types/pocketbase/equipment.ts @@ -0,0 +1,35 @@ +import { BaseRecord } from '@/types/pocketbase/base' + +/** + * Equipment record interface + * Represents the data structure for equipment items in the system + */ +export interface Equipment extends BaseRecord { + organization: string + name: string + qrNfcCode: string + tags: string + notes: string + acquisitionDate: string + parentEquipment?: string +} + +/** + * Type definition for equipment creation input + * Contains all fields required when creating a new equipment + */ +export interface EquipmentCreateInput { + organization: string + name: string + qrNfcCode: string + tags?: string + notes?: string + acquisitionDate?: string + parentEquipment?: string +} + +/** + * Type definition for equipment update input + * All fields are optional as updates can be partial + */ +export type EquipmentUpdateInput = Partial diff --git a/src/types/pocketbase/index.ts b/src/types/pocketbase/index.ts new file mode 100644 index 0000000..665a158 --- /dev/null +++ b/src/types/pocketbase/index.ts @@ -0,0 +1,18 @@ +/** + * PocketBase types exports + * This file centralizes all PocketBase related type exports for easier imports + */ + +// Base types +export * from '@/types/pocketbase/base' + +// Collection names +export * from '@/types/pocketbase/collections' + +// Entity types +export * from '@/types/pocketbase/equipment' + +// Add other entity types as they are created: +// export * from './organization' +// export * from './app-user' +// etc. From c746a9ee716f40162848e8078fc6c7f32fd1519f Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 14:13:04 +0200 Subject: [PATCH 48/73] feat(models): reorganize types and validation schemas - Rename `/types` directory to `/models` for clarity - Centralize type definitions and validation schemas in model files - Introduce consistent naming convention with `.model.ts` suffix - Update service imports to reflect new models structure - Enhance maintainability by colocating types with their validation schemas --- README.md | 41 +++--- .../services/pocketbase/equipment_service.ts | 12 +- src/models/pocketbase/base.model.ts | 134 ++++++++++++++++++ src/models/pocketbase/collections.model.ts | 13 ++ src/models/pocketbase/equipment.model.ts | 104 ++++++++++++++ src/models/pocketbase/index.ts | 19 +++ 6 files changed, 296 insertions(+), 27 deletions(-) create mode 100644 src/models/pocketbase/base.model.ts create mode 100644 src/models/pocketbase/collections.model.ts create mode 100644 src/models/pocketbase/equipment.model.ts create mode 100644 src/models/pocketbase/index.ts diff --git a/README.md b/README.md index 87da603..0b0dabd 100644 --- a/README.md +++ b/README.md @@ -31,36 +31,36 @@ A SaaS platform for managing equipment inventory and tracking using QR codes and The project follows a modular structure with clear separation of concerns: -### Types Organization +### Models Organization -All types are centralized in the `/types` directory to avoid duplication and maintain DRY principles: +All models are centralized in the `/models` directory, combining types and validation schemas in a cohesive location: ``` -/types - /pocketbase # PocketBase related types - /base.ts # Base types (BaseRecord, ListResult, etc.) - /collections.ts # Collection name constants - /equipment.ts # Equipment specific types - /index.ts # Re-exports for easier imports - # Other type categories can be added here +/models + /pocketbase # PocketBase related models + /base.model.ts # Base types, schemas and utilities + /collections.model.ts # Collection name constants + /equipment.model.ts # Equipment specific types and schemas + /index.ts # Re-exports for easier imports + # Other model categories can be added here ``` -### Validation Schemas +Each model file follows a consistent pattern: -Zod validation schemas are centralized in the `/schemas` directory: +1. Type definitions section (interfaces, types) +2. Schema definitions section (Zod validation schemas) +3. Helper functions (if needed) -``` -/schemas - /pocketbase # PocketBase related schemas - /base.ts # Base schemas and utility functions - /equipment.ts # Equipment specific schemas - /index.ts # Re-exports for easier imports - # Other schema categories can be added here -``` +This approach offers several benefits: + +- **Colocation** - Types and their validation schemas are kept together +- **Discoverability** - Easy to find both the type and its validation in one place +- **Maintainability** - Changes to a model only require editing a single file +- **Consistency** - Consistent naming convention with `.model.ts` suffix ### Services -Services use the centralized types and schemas: +Services use the centralized models: ``` /app/actions/services @@ -76,3 +76,4 @@ This structure ensures: - Clear separation of concerns - Easy maintenance and refactoring - Type safety across the application +- Logical grouping of related code diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts index a33c831..910d100 100644 --- a/src/app/actions/services/pocketbase/equipment_service.ts +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -1,16 +1,14 @@ 'use server' -import { - equipmentCreateSchema, - equipmentSchema, - equipmentUpdateSchema, -} from '@/schemas/pocketbase' import { Collections, Equipment, EquipmentCreateInput, EquipmentUpdateInput, -} from '@/types/pocketbase' + equipmentCreateSchema, + equipmentSchema, + equipmentUpdateSchema, +} from '@/models/pocketbase' import { BaseService } from './api_client' @@ -27,7 +25,7 @@ export class EquipmentService extends BaseService< EquipmentUpdateInput > { constructor() { - // @eslint-disable-next-line @typescript-eslint/ban-ts-comment @ts-expect-error - Types are compatible but TypeScript cannot verify it + // @ts-expect-error - Types are compatible but TypeScript cannot verify it super( Collections.EQUIPMENT, equipmentSchema, diff --git a/src/models/pocketbase/base.model.ts b/src/models/pocketbase/base.model.ts new file mode 100644 index 0000000..08c463a --- /dev/null +++ b/src/models/pocketbase/base.model.ts @@ -0,0 +1,134 @@ +import { z } from 'zod' + +/** + * ======================================== + * BASE TYPES + * ======================================== + */ + +/** + * Base interface for all PocketBase records + * Contains system fields present in every PocketBase record + */ +export interface BaseRecord { + id: string + created: string + updated: string + collectionId?: string + collectionName?: string +} + +/** + * Generic list result interface for paginated PocketBase responses + */ +export interface ListResult { + page: number + perPage: number + totalItems: number + totalPages: number + items: T[] +} + +/** + * Generic query parameters for list operations + */ +export interface QueryParams { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} + +/** + * ======================================== + * BASE SCHEMAS + * ======================================== + */ + +/** + * Base schema for all PocketBase records + * Contains validation for system fields present in all records + */ +export const baseRecordSchema = z.object({ + collectionId: z.string().optional(), + collectionName: z.string().optional(), + created: z.string().datetime(), + id: z.string(), + updated: z.string().datetime(), +}) + +/** + * List result schema (generic) + * Used for paginated results from PocketBase + */ +export const listResultSchema = (itemSchema: T) => + z.object({ + items: z.array(itemSchema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + +/** + * Query parameters schema + * Validates parameters for list operations + */ +export const queryParamsSchema = z.object({ + expand: z.string().optional(), + filter: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), + sort: z.string().optional(), +}) + +/** + * ======================================== + * UTILITY FUNCTIONS + * ======================================== + */ + +/** + * Create partial schema by making all properties optional + * Useful for update operations + */ +export function createPartialSchema( + schema: z.ZodType +): z.ZodType { + // Handle ZodObject specifically + if (schema instanceof z.ZodObject) { + return schema.partial() + } + + // For non-object schemas, we can't properly partial them + // Just return the original schema as a fallback + console.warn('Attempting to create partial schema for non-object type') + return schema as z.ZodType +} + +/** + * Create schemas for CRUD operations based on a base schema + */ +export function createServiceSchemas(baseSchema: z.ZodType) { + // For create operations, strip system fields + const createSchema = + baseSchema instanceof z.ZodObject + ? baseSchema.omit({ + collectionId: true, + collectionName: true, + created: true, + id: true, + updated: true, + }) + : baseSchema + + // For update operations, make all properties optional + const updateSchema = createPartialSchema(createSchema as z.ZodType) + + return { + baseSchema, + createSchema, + updateSchema, + } +} diff --git a/src/models/pocketbase/collections.model.ts b/src/models/pocketbase/collections.model.ts new file mode 100644 index 0000000..1c0af0f --- /dev/null +++ b/src/models/pocketbase/collections.model.ts @@ -0,0 +1,13 @@ +/** + * Collection names used throughout the application + * Centralizing these constants prevents typos and makes refactoring easier + */ +export enum Collections { + ACTIVITY_LOGS = 'activity_logs', + APP_USERS = 'app_users', + ASSIGNMENTS = 'assignments', + EQUIPMENT = 'equipment', + IMAGES = 'images', + ORGANIZATIONS = 'organizations', + PROJECTS = 'projects', +} diff --git a/src/models/pocketbase/equipment.model.ts b/src/models/pocketbase/equipment.model.ts new file mode 100644 index 0000000..db711af --- /dev/null +++ b/src/models/pocketbase/equipment.model.ts @@ -0,0 +1,104 @@ +import { z } from 'zod' + +import { + BaseRecord, + baseRecordSchema, + createServiceSchemas, +} from './base.model' + +/** + * ======================================== + * EQUIPMENT TYPES + * ======================================== + */ + +/** + * Equipment record interface + * Represents the data structure for equipment items in the system + */ +export interface Equipment extends BaseRecord { + organization: string + name: string + qrNfcCode: string + tags: string + notes: string + acquisitionDate: string + parentEquipment?: string +} + +/** + * Type definition for equipment creation input + * Contains all fields required when creating a new equipment + */ +export interface EquipmentCreateInput { + organization: string + name: string + qrNfcCode: string + tags?: string + notes?: string + acquisitionDate?: string + parentEquipment?: string +} + +/** + * Type definition for equipment update input + * All fields are optional as updates can be partial + */ +export type EquipmentUpdateInput = Partial + +/** + * ======================================== + * EQUIPMENT SCHEMAS + * ======================================== + */ + +/** + * Equipment schema for validation + * Defines validation rules for equipment records from PocketBase + */ +export const equipmentSchema = baseRecordSchema.extend({ + acquisitionDate: z.string(), + name: z.string(), + notes: z.string(), + organization: z.string(), + parentEquipment: z.string().optional(), + qrNfcCode: z.string(), + tags: z.string(), +}) + +/** + * Generated schemas for equipment CRUD operations + * We use the update schema but create a custom create schema + */ +const { updateSchema } = createServiceSchemas(equipmentSchema) + +/** + * Schema for equipment creation + * Use this for validating data when creating new equipment + */ +export const equipmentCreateSchema = z + .object({ + acquisitionDate: z.string().optional(), + name: z.string(), + notes: z.string().optional(), + organization: z.string(), + parentEquipment: z.string().optional(), + qrNfcCode: z.string(), + tags: z.string().optional(), + }) + .transform(data => { + // Ensure all string fields have default values + return { + ...data, + acquisitionDate: data.acquisitionDate || '', + notes: data.notes || '', + tags: data.tags || '', + } + }) as z.ZodType + +/** + * Schema for equipment updates + * Use this for validating data when updating equipment + */ +export const equipmentUpdateSchema = + updateSchema as z.ZodType diff --git a/src/models/pocketbase/index.ts b/src/models/pocketbase/index.ts new file mode 100644 index 0000000..b3ef012 --- /dev/null +++ b/src/models/pocketbase/index.ts @@ -0,0 +1,19 @@ +/** + * PocketBase models exports + * This file centralizes all PocketBase related exports for easier imports + * Models combine both types and validation schemas in a single location + */ + +// Base models +export * from './base.model' + +// Collection names +export * from './collections.model' + +// Entity models +export * from './equipment.model' + +// Add other entity models as they are created: +// export * from './organization.model' +// export * from './app-user.model' +// etc. From 7b11a19b25bbe3347af5551a23c5c4fafa80fe58 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 14:20:40 +0200 Subject: [PATCH 49/73] feat(models): add organization and app user models - Introduce new models for organization and app user - Update README to reflect new model structure - Modify services to utilize centralized model imports - Address TypeScript compatibility issues with utility functions - Remove outdated API client exports and schemas --- README.md | 6 + .../services/clerk-sync/syncService.ts | 3 +- .../services/pocketbase/api_client/index.ts | 59 ------- .../services/pocketbase/app_user_service.ts | 30 ++-- .../services/pocketbase/base_service_fix.ts | 13 ++ .../services/pocketbase/equipment_service.ts | 2 +- .../pocketbase/organization_service.ts | 27 +-- .../pocketbase/secured/equipment_service.ts | 2 +- src/models/pocketbase/app-user.model.ts | 165 ++++++++++++++++++ src/models/pocketbase/equipment.model.ts | 5 +- src/models/pocketbase/index.ts | 12 +- src/models/pocketbase/organization.model.ts | 100 +++++++++++ src/schemas/pocketbase/base.ts | 82 --------- src/schemas/pocketbase/equipment.ts | 61 ------- src/schemas/pocketbase/index.ts | 15 -- src/types/pocketbase/base.ts | 38 ---- src/types/pocketbase/collections.ts | 13 -- src/types/pocketbase/equipment.ts | 35 ---- src/types/pocketbase/index.ts | 18 -- 19 files changed, 331 insertions(+), 355 deletions(-) delete mode 100644 src/app/actions/services/pocketbase/api_client/index.ts create mode 100644 src/app/actions/services/pocketbase/base_service_fix.ts create mode 100644 src/models/pocketbase/app-user.model.ts create mode 100644 src/models/pocketbase/organization.model.ts delete mode 100644 src/schemas/pocketbase/base.ts delete mode 100644 src/schemas/pocketbase/equipment.ts delete mode 100644 src/schemas/pocketbase/index.ts delete mode 100644 src/types/pocketbase/base.ts delete mode 100644 src/types/pocketbase/collections.ts delete mode 100644 src/types/pocketbase/equipment.ts delete mode 100644 src/types/pocketbase/index.ts diff --git a/README.md b/README.md index 0b0dabd..88126b9 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ All models are centralized in the `/models` directory, combining types and valid /base.model.ts # Base types, schemas and utilities /collections.model.ts # Collection name constants /equipment.model.ts # Equipment specific types and schemas + /organization.model.ts # Organization specific types and schemas + /app-user.model.ts # App user specific types and schemas /index.ts # Re-exports for easier imports # Other model categories can be added here ``` @@ -58,6 +60,8 @@ This approach offers several benefits: - **Maintainability** - Changes to a model only require editing a single file - **Consistency** - Consistent naming convention with `.model.ts` suffix +> **Note on TypeScript compatibility:** There are some TypeScript compatibility issues between Zod schemas and TypeScript interfaces. We've addressed these with `@ts-expect-error` comments in the service constructors. These are harmless and don't affect runtime functionality. + ### Services Services use the centralized models: @@ -67,6 +71,8 @@ Services use the centralized models: /pocketbase /api_client # Base API client /equipment_service.ts # Equipment specific service + /organization_service.ts # Organization specific service + /app_user_service.ts # App user specific service # Other services ``` diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index 7cca999..b07c1de 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -5,7 +5,8 @@ import type { Organization as ClerkOrganization, } from '@clerk/nextjs/server' -import { AppUser, Organization } from '../pocketbase/api_client/types' +import { AppUser, Organization } from '@/models/pocketbase' + import { getAppUserService, AppUserCreateInput, diff --git a/src/app/actions/services/pocketbase/api_client/index.ts b/src/app/actions/services/pocketbase/api_client/index.ts deleted file mode 100644 index b9e84c5..0000000 --- a/src/app/actions/services/pocketbase/api_client/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * PocketBase API Client exports - */ - -// Client and utilities -export * from '@/app/actions/services/pocketbase/api_client/client' -export * from '@/app/actions/services/pocketbase/api_client/base_service' - -// Types and schemas -export * from '@/app/actions/services/pocketbase/api_client/types' -export * from '@/app/actions/services/pocketbase/api_client/schemas' - -// Re-export common utility for creating service inputs -import { z } from 'zod' - -/** - * Create partial schema by making all properties optional - * Useful for update operations - */ -export function createPartialSchema( - schema: z.ZodType -): z.ZodType { - // Handle ZodObject specifically - if (schema instanceof z.ZodObject) { - return schema.partial() - } - - // For non-object schemas, we can't properly partial them - // Just return the original schema as a fallback - console.warn('Attempting to create partial schema for non-object type') - return schema as z.ZodType -} - -/** - * Create schemas for CRUD operations based on a base schema - */ -export function createServiceSchemas(baseSchema: z.ZodType) { - // For create operations, strip system fields - const createSchema = - baseSchema instanceof z.ZodObject - ? baseSchema.omit({ - collectionId: true, - collectionName: true, - created: true, - id: true, - updated: true, - }) - : baseSchema - - // For update operations, make all properties optional - // We need to cast the schema to avoid TypeScript errors - const updateSchema = createPartialSchema(createSchema as z.ZodType) - - return { - baseSchema, - createSchema, - updateSchema, - } -} diff --git a/src/app/actions/services/pocketbase/app_user_service.ts b/src/app/actions/services/pocketbase/app_user_service.ts index cf16327..7ed9d49 100644 --- a/src/app/actions/services/pocketbase/app_user_service.ts +++ b/src/app/actions/services/pocketbase/app_user_service.ts @@ -1,17 +1,19 @@ 'use server' -import { z } from 'zod' - -import { BaseService, Collections, createServiceSchemas } from './api_client' -import { appUserSchema } from './api_client/schemas' -import { AppUser } from './api_client/types' +import { + AppUser, + AppUserCreateInput, + AppUserUpdateInput, + Collections, + appUserCreateSchema, + appUserSchema, + appUserUpdateSchema, +} from '@/models/pocketbase' -// Create schemas for app user operations -const { createSchema, updateSchema } = createServiceSchemas(appUserSchema) +import { BaseService } from './api_client' -// Types based on the schemas -export type AppUserCreateInput = z.infer -export type AppUserUpdateInput = z.infer +// Re-export types for convenience +export type { AppUser, AppUserCreateInput, AppUserUpdateInput } /** * Service for AppUser-related operations @@ -22,7 +24,13 @@ export class AppUserService extends BaseService< AppUserUpdateInput > { constructor() { - super(Collections.APP_USERS, appUserSchema, createSchema, updateSchema) + // @ts-expect-error - Types are compatible but TypeScript cannot verify it + super( + Collections.APP_USERS, + appUserSchema, + appUserCreateSchema, + appUserUpdateSchema + ) } /** diff --git a/src/app/actions/services/pocketbase/base_service_fix.ts b/src/app/actions/services/pocketbase/base_service_fix.ts new file mode 100644 index 0000000..8b1f8cd --- /dev/null +++ b/src/app/actions/services/pocketbase/base_service_fix.ts @@ -0,0 +1,13 @@ +/** + * Utility function to fix type compatibility issues + * This function is a workaround for TypeScript type compatibility issues + * between Zod schemas and TypeScript interfaces + * + * @param schema The Zod schema to be fixed + * @returns The same schema with fixed type compatibility + */ +export function fixSchemaType(schema: any): any { + // Simply pass through the schema but with a different type signature + // This is a type assertion hack to make TypeScript happy + return schema +} diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts index 910d100..fb6f3d2 100644 --- a/src/app/actions/services/pocketbase/equipment_service.ts +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -25,9 +25,9 @@ export class EquipmentService extends BaseService< EquipmentUpdateInput > { constructor() { - // @ts-expect-error - Types are compatible but TypeScript cannot verify it super( Collections.EQUIPMENT, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] equipmentSchema, equipmentCreateSchema, equipmentUpdateSchema diff --git a/src/app/actions/services/pocketbase/organization_service.ts b/src/app/actions/services/pocketbase/organization_service.ts index 7674039..93fe25e 100644 --- a/src/app/actions/services/pocketbase/organization_service.ts +++ b/src/app/actions/services/pocketbase/organization_service.ts @@ -1,17 +1,19 @@ 'use server' -import { z } from 'zod' - -import { BaseService, Collections, createServiceSchemas } from './api_client' -import { organizationSchema } from './api_client/schemas' -import { Organization } from './api_client/types' +import { + Collections, + Organization, + OrganizationCreateInput, + OrganizationUpdateInput, + organizationCreateSchema, + organizationSchema, + organizationUpdateSchema, +} from '@/models/pocketbase' -// Create schemas for organization operations -const { createSchema, updateSchema } = createServiceSchemas(organizationSchema) +import { BaseService } from './api_client' -// Types based on the schemas -export type OrganizationCreateInput = z.infer -export type OrganizationUpdateInput = z.infer +// Re-export types for convenience +export type { Organization, OrganizationCreateInput, OrganizationUpdateInput } /** * Service for Organization-related operations @@ -24,9 +26,10 @@ export class OrganizationService extends BaseService< constructor() { super( Collections.ORGANIZATIONS, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] organizationSchema, - createSchema, - updateSchema + organizationCreateSchema, + organizationUpdateSchema ) } diff --git a/src/app/actions/services/pocketbase/secured/equipment_service.ts b/src/app/actions/services/pocketbase/secured/equipment_service.ts index 619a972..575a508 100644 --- a/src/app/actions/services/pocketbase/secured/equipment_service.ts +++ b/src/app/actions/services/pocketbase/secured/equipment_service.ts @@ -1,6 +1,5 @@ 'use server' -import { Equipment } from '@/app/actions/services/pocketbase/api_client/types' import { EquipmentCreateInput, EquipmentUpdateInput, @@ -12,6 +11,7 @@ import { checkResourcePermission, withSecurity, } from '@/app/actions/services/pocketbase/secured/security_middleware' +import { Equipment } from '@/models/pocketbase' /** * Get equipment by ID with security checks diff --git a/src/models/pocketbase/app-user.model.ts b/src/models/pocketbase/app-user.model.ts new file mode 100644 index 0000000..5d9a3f3 --- /dev/null +++ b/src/models/pocketbase/app-user.model.ts @@ -0,0 +1,165 @@ +import { + BaseRecord, + baseRecordSchema, + createServiceSchemas, +} from '@/models/pocketbase/base.model' +import { z } from 'zod' + +/** + * ======================================== + * APP USER TYPES + * ======================================== + */ + +/** + * AppUser record interface + */ +export interface AppUser extends BaseRecord { + email: string + emailVisibility: boolean + verified: boolean + name: string + role: string + isAdmin: boolean + lastLogin: string + clerkId: string + organizations: string + metadata: { + createdAt: number + externalAccounts?: Array<{ + email: string + imageUrl: string + provider: string + providerUserId: string + }> + hasCompletedOnboarding?: boolean + lastActiveAt?: number + onboardingCompletedAt?: string + public?: { + hasCompletedOnboarding: boolean + onboardingCompletedAt: string + } + updatedAt?: number + } +} + +/** + * Type definition for app user creation input + */ +export interface AppUserCreateInput { + email: string + emailVisibility?: boolean + verified?: boolean + name?: string + role?: string + isAdmin?: boolean + lastLogin?: string + clerkId: string + organizations?: string + metadata?: { + createdAt: number + externalAccounts?: Array<{ + email: string + imageUrl: string + provider: string + providerUserId: string + }> + hasCompletedOnboarding?: boolean + lastActiveAt?: number + onboardingCompletedAt?: string + public?: { + hasCompletedOnboarding: boolean + onboardingCompletedAt: string + } + updatedAt?: number + } +} + +/** + * Type definition for app user update input + */ +export type AppUserUpdateInput = Partial + +/** + * ======================================== + * APP USER SCHEMAS + * ======================================== + */ + +/** + * External account schema (for metadata) + */ +const externalAccountSchema = z.object({ + email: z.string().email(), + imageUrl: z.string().url(), + provider: z.string(), + providerUserId: z.string(), +}) + +/** + * Metadata public schema (for metadata) + */ +const metadataPublicSchema = z.object({ + hasCompletedOnboarding: z.boolean(), + onboardingCompletedAt: z.string(), +}) + +/** + * Metadata schema for app user + */ +const metadataSchema = z + .object({ + createdAt: z.number().optional(), + externalAccounts: z.array(externalAccountSchema).optional(), + hasCompletedOnboarding: z.boolean().optional(), + lastActiveAt: z.number().optional(), + onboardingCompletedAt: z.string().optional(), + public: metadataPublicSchema.optional(), + updatedAt: z.number().optional(), + }) + .optional() + .default({}) + +/** + * AppUser schema for validation + */ +export const appUserSchema = baseRecordSchema.extend({ + clerkId: z.string(), + email: z.string().email(), + emailVisibility: z.boolean().default(true), + isAdmin: z.boolean().default(false), + lastLogin: z.string().optional().or(z.literal('')), + metadata: metadataSchema, + name: z.string().optional().or(z.literal('')), + organizations: z.string().optional().or(z.literal('')), + role: z.string().optional().or(z.literal('')), + verified: z.boolean().default(false), +}) + +/** + * Generated schemas for app user CRUD operations + */ +const { createSchema, updateSchema } = createServiceSchemas(appUserSchema) + +/** + * Schema for app user creation + */ +export const appUserCreateSchema = createSchema.transform(data => { + // Ensure all optional fields have default values + return { + ...data, + emailVisibility: data.emailVisibility ?? true, + isAdmin: data.isAdmin ?? false, + lastLogin: data.lastLogin || '', + metadata: data.metadata || { createdAt: Date.now() }, + name: data.name || '', + organizations: data.organizations || '', + role: data.role || '', + verified: data.verified ?? false, + } +}) as z.ZodType + +/** + * Schema for app user updates + */ +export const appUserUpdateSchema = updateSchema as z.ZodType diff --git a/src/models/pocketbase/equipment.model.ts b/src/models/pocketbase/equipment.model.ts index db711af..857d56e 100644 --- a/src/models/pocketbase/equipment.model.ts +++ b/src/models/pocketbase/equipment.model.ts @@ -1,10 +1,9 @@ -import { z } from 'zod' - import { BaseRecord, baseRecordSchema, createServiceSchemas, -} from './base.model' +} from '@/models/pocketbase/base.model' +import { z } from 'zod' /** * ======================================== diff --git a/src/models/pocketbase/index.ts b/src/models/pocketbase/index.ts index b3ef012..ef3eb28 100644 --- a/src/models/pocketbase/index.ts +++ b/src/models/pocketbase/index.ts @@ -5,15 +5,17 @@ */ // Base models -export * from './base.model' +export * from '@/models/pocketbase/base.model' // Collection names -export * from './collections.model' +export * from '@/models/pocketbase/collections.model' // Entity models -export * from './equipment.model' +export * from '@/models/pocketbase/app-user.model' +export * from '@/models/pocketbase/equipment.model' +export * from '@/models/pocketbase/organization.model' // Add other entity models as they are created: -// export * from './organization.model' -// export * from './app-user.model' +// export * from './project.model' +// export * from './assignment.model' // etc. diff --git a/src/models/pocketbase/organization.model.ts b/src/models/pocketbase/organization.model.ts new file mode 100644 index 0000000..8b4fd80 --- /dev/null +++ b/src/models/pocketbase/organization.model.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' + +import { + BaseRecord, + baseRecordSchema, + createServiceSchemas, +} from './base.model' + +/** + * ======================================== + * ORGANIZATION TYPES + * ======================================== + */ + +/** + * Organization record interface + */ +export interface Organization extends BaseRecord { + name: string + email: string + phone: string + address: string + settings: Record + clerkId: string + stripeCustomerId: string + subscriptionId: string + subscriptionStatus: string + priceId: string +} + +/** + * Type definition for organization creation input + */ +export interface OrganizationCreateInput { + name: string + email?: string + phone?: string + address?: string + settings?: Record + clerkId: string + stripeCustomerId?: string + subscriptionId?: string + subscriptionStatus?: string + priceId?: string +} + +/** + * Type definition for organization update input + */ +export type OrganizationUpdateInput = Partial + +/** + * ======================================== + * ORGANIZATION SCHEMAS + * ======================================== + */ + +/** + * Organization schema for validation + */ +export const organizationSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + clerkId: z.string(), + email: z.string().email().optional().or(z.literal('')), + name: z.string(), + phone: z.string().optional().or(z.literal('')), + priceId: z.string().optional().or(z.literal('')), + settings: z.record(z.string(), z.unknown()).optional().default({}), + stripeCustomerId: z.string().optional().or(z.literal('')), + subscriptionId: z.string().optional().or(z.literal('')), + subscriptionStatus: z.string().optional().or(z.literal('')), +}) + +/** + * Generated schemas for organization CRUD operations + */ +const { createSchema, updateSchema } = createServiceSchemas(organizationSchema) + +/** + * Schema for organization creation + */ +export const organizationCreateSchema = createSchema.transform(data => { + // Ensure all string fields have default values + return { + ...data, + address: data.address || '', + email: data.email || '', + phone: data.phone || '', + priceId: data.priceId || '', + stripeCustomerId: data.stripeCustomerId || '', + subscriptionId: data.subscriptionId || '', + subscriptionStatus: data.subscriptionStatus || '', + } +}) as z.ZodType + +/** + * Schema for organization updates + */ +export const organizationUpdateSchema = + updateSchema as z.ZodType diff --git a/src/schemas/pocketbase/base.ts b/src/schemas/pocketbase/base.ts deleted file mode 100644 index b23650c..0000000 --- a/src/schemas/pocketbase/base.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { z } from 'zod' - -/** - * Base schema for all PocketBase records - * Contains validation for system fields present in all records - */ -export const baseRecordSchema = z.object({ - collectionId: z.string().optional(), - collectionName: z.string().optional(), - created: z.string().datetime(), - id: z.string(), - updated: z.string().datetime(), -}) - -/** - * List result schema (generic) - * Used for paginated results from PocketBase - */ -export const listResultSchema = (itemSchema: T) => - z.object({ - items: z.array(itemSchema), - page: z.number(), - perPage: z.number(), - totalItems: z.number(), - totalPages: z.number(), - }) - -/** - * Query parameters schema - * Validates parameters for list operations - */ -export const queryParamsSchema = z.object({ - expand: z.string().optional(), - filter: z.string().optional(), - page: z.number().optional(), - perPage: z.number().optional(), - sort: z.string().optional(), -}) - -/** - * Create partial schema by making all properties optional - * Useful for update operations - */ -export function createPartialSchema( - schema: z.ZodType -): z.ZodType { - // Handle ZodObject specifically - if (schema instanceof z.ZodObject) { - return schema.partial() - } - - // For non-object schemas, we can't properly partial them - // Just return the original schema as a fallback - console.warn('Attempting to create partial schema for non-object type') - return schema as z.ZodType -} - -/** - * Create schemas for CRUD operations based on a base schema - */ -export function createServiceSchemas(baseSchema: z.ZodType) { - // For create operations, strip system fields - const createSchema = - baseSchema instanceof z.ZodObject - ? baseSchema.omit({ - collectionId: true, - collectionName: true, - created: true, - id: true, - updated: true, - }) - : baseSchema - - // For update operations, make all properties optional - const updateSchema = createPartialSchema(createSchema as z.ZodType) - - return { - baseSchema, - createSchema, - updateSchema, - } -} diff --git a/src/schemas/pocketbase/equipment.ts b/src/schemas/pocketbase/equipment.ts deleted file mode 100644 index 893008f..0000000 --- a/src/schemas/pocketbase/equipment.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { - EquipmentCreateInput, - EquipmentUpdateInput, -} from '@/types/pocketbase' - -import { - baseRecordSchema, - createServiceSchemas, -} from '@/schemas/pocketbase/base' -import { z } from 'zod' - -/** - * Equipment schema for validation - * Defines validation rules for equipment records from PocketBase - */ -export const equipmentSchema = baseRecordSchema.extend({ - acquisitionDate: z.string(), - name: z.string(), - notes: z.string(), - organization: z.string(), - parentEquipment: z.string().optional(), - qrNfcCode: z.string(), - tags: z.string(), -}) - -/** - * Generated schemas for equipment CRUD operations - * We use the update schema but create a custom create schema - */ -const { updateSchema } = createServiceSchemas(equipmentSchema) - -/** - * Schema for equipment creation - * Use this for validating data when creating new equipment - */ -export const equipmentCreateSchema = z - .object({ - acquisitionDate: z.string().optional(), - name: z.string(), - notes: z.string().optional(), - organization: z.string(), - parentEquipment: z.string().optional(), - qrNfcCode: z.string(), - tags: z.string().optional(), - }) - .transform(data => { - // Ensure all string fields have default values - return { - ...data, - acquisitionDate: data.acquisitionDate || '', - notes: data.notes || '', - tags: data.tags || '', - } - }) as z.ZodType - -/** - * Schema for equipment updates - * Use this for validating data when updating equipment - */ -export const equipmentUpdateSchema = - updateSchema as z.ZodType diff --git a/src/schemas/pocketbase/index.ts b/src/schemas/pocketbase/index.ts deleted file mode 100644 index 9d675e3..0000000 --- a/src/schemas/pocketbase/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * PocketBase schemas exports - * This file centralizes all PocketBase related schema exports for easier imports - */ - -// Base schemas and utilities -export * from '@/schemas/pocketbase/base' - -// Entity schemas -export * from '@/schemas/pocketbase/equipment' - -// Add other entity schemas as they are created: -// export * from './organization' -// export * from './app-user' -// etc. diff --git a/src/types/pocketbase/base.ts b/src/types/pocketbase/base.ts deleted file mode 100644 index de8dbe6..0000000 --- a/src/types/pocketbase/base.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Base types for PocketBase data models - * These types represent the common schema structure across all collections - */ - -/** - * Base interface for all PocketBase records - * Contains system fields present in every PocketBase record - */ -export interface BaseRecord { - id: string - created: string - updated: string - collectionId?: string - collectionName?: string -} - -/** - * Generic list result interface for paginated PocketBase responses - */ -export interface ListResult { - page: number - perPage: number - totalItems: number - totalPages: number - items: T[] -} - -/** - * Generic query parameters for list operations - */ -export interface QueryParams { - page?: number - perPage?: number - sort?: string - filter?: string - expand?: string -} diff --git a/src/types/pocketbase/collections.ts b/src/types/pocketbase/collections.ts deleted file mode 100644 index 1c0af0f..0000000 --- a/src/types/pocketbase/collections.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Collection names used throughout the application - * Centralizing these constants prevents typos and makes refactoring easier - */ -export enum Collections { - ACTIVITY_LOGS = 'activity_logs', - APP_USERS = 'app_users', - ASSIGNMENTS = 'assignments', - EQUIPMENT = 'equipment', - IMAGES = 'images', - ORGANIZATIONS = 'organizations', - PROJECTS = 'projects', -} diff --git a/src/types/pocketbase/equipment.ts b/src/types/pocketbase/equipment.ts deleted file mode 100644 index cf51efa..0000000 --- a/src/types/pocketbase/equipment.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { BaseRecord } from '@/types/pocketbase/base' - -/** - * Equipment record interface - * Represents the data structure for equipment items in the system - */ -export interface Equipment extends BaseRecord { - organization: string - name: string - qrNfcCode: string - tags: string - notes: string - acquisitionDate: string - parentEquipment?: string -} - -/** - * Type definition for equipment creation input - * Contains all fields required when creating a new equipment - */ -export interface EquipmentCreateInput { - organization: string - name: string - qrNfcCode: string - tags?: string - notes?: string - acquisitionDate?: string - parentEquipment?: string -} - -/** - * Type definition for equipment update input - * All fields are optional as updates can be partial - */ -export type EquipmentUpdateInput = Partial diff --git a/src/types/pocketbase/index.ts b/src/types/pocketbase/index.ts deleted file mode 100644 index 665a158..0000000 --- a/src/types/pocketbase/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * PocketBase types exports - * This file centralizes all PocketBase related type exports for easier imports - */ - -// Base types -export * from '@/types/pocketbase/base' - -// Collection names -export * from '@/types/pocketbase/collections' - -// Entity types -export * from '@/types/pocketbase/equipment' - -// Add other entity types as they are created: -// export * from './organization' -// export * from './app-user' -// etc. From 15fb806ab167023c39a8f8a9d05eccb0de1387e1 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 14:36:34 +0200 Subject: [PATCH 50/73] feat(api): restructure PocketBase API client - Move base service and schemas to a centralized export file - Remove outdated schema and type files for cleaner codebase - Update import paths for better organization - Add new equipment schema with validation rules - Ensure services utilize the updated base service structure --- .../pocketbase/api_client/base_service.ts | 7 +- .../services/pocketbase/api_client/index.ts | 42 +++++ .../services/pocketbase/api_client/schemas.ts | 154 ------------------ .../services/pocketbase/api_client/types.ts | 143 ---------------- .../services/pocketbase/app_user_service.ts | 5 +- .../services/pocketbase/base_service_fix.ts | 13 -- .../services/pocketbase/equipment_service.ts | 5 +- src/app/actions/services/pocketbase/index.ts | 12 +- .../pocketbase/organization_service.ts | 3 +- .../webhook/clerk/admin/reconcile/route.ts | 1 - src/schemas/pocketbase/equipment.ts | 58 +++++++ 11 files changed, 113 insertions(+), 330 deletions(-) create mode 100644 src/app/actions/services/pocketbase/api_client/index.ts delete mode 100644 src/app/actions/services/pocketbase/api_client/schemas.ts delete mode 100644 src/app/actions/services/pocketbase/api_client/types.ts delete mode 100644 src/app/actions/services/pocketbase/base_service_fix.ts create mode 100644 src/schemas/pocketbase/equipment.ts diff --git a/src/app/actions/services/pocketbase/api_client/base_service.ts b/src/app/actions/services/pocketbase/api_client/base_service.ts index 3db1e1d..0659180 100644 --- a/src/app/actions/services/pocketbase/api_client/base_service.ts +++ b/src/app/actions/services/pocketbase/api_client/base_service.ts @@ -2,16 +2,15 @@ * Generic base service for PocketBase CRUD operations */ -import { z } from 'zod' - import { getPocketBase, handlePocketBaseError, CollectionMethodOptions, defaultCollectionMethodOptions, validateWithZod, -} from './client' -import { ListResult, QueryParams } from './types' +} from '@/app/actions/services/pocketbase/api_client/client' +import { ListResult, QueryParams } from '@/models/pocketbase' +import { z } from 'zod' /** * Base service class for PocketBase collections diff --git a/src/app/actions/services/pocketbase/api_client/index.ts b/src/app/actions/services/pocketbase/api_client/index.ts new file mode 100644 index 0000000..bcd7eb5 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/index.ts @@ -0,0 +1,42 @@ +/** + * PocketBase API Client exports + */ + +// Client and utilities +export * from '@/app/actions/services/pocketbase/api_client/client' +export * from '@/app/actions/services/pocketbase/api_client/base_service' + +// Re-export schemas and values from central location except Collections (to avoid conflict) +export { + // Re-export value exports (non-types) + appUserCreateSchema, + appUserSchema, + appUserUpdateSchema, + baseRecordSchema, + createPartialSchema, + createServiceSchemas, + equipmentCreateSchema, + equipmentSchema, + equipmentUpdateSchema, + listResultSchema, + organizationCreateSchema, + organizationSchema, + organizationUpdateSchema, + queryParamsSchema, +} from '@/models/pocketbase' + +// Re-export types separately to avoid 'isolatedModules' error +export type { + AppUser, + AppUserCreateInput, + AppUserUpdateInput, + BaseRecord, + Equipment, + EquipmentCreateInput, + EquipmentUpdateInput, + ListResult, + Organization, + OrganizationCreateInput, + OrganizationUpdateInput, + QueryParams, +} from '@/models/pocketbase' diff --git a/src/app/actions/services/pocketbase/api_client/schemas.ts b/src/app/actions/services/pocketbase/api_client/schemas.ts deleted file mode 100644 index bed285f..0000000 --- a/src/app/actions/services/pocketbase/api_client/schemas.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Zod schemas for PocketBase data models - * These schemas are used for validation of data going in and out of PocketBase - */ -import { z } from 'zod' - -/** - * Base schema for all PocketBase records - */ -export const baseRecordSchema = z.object({ - collectionId: z.string().optional(), - collectionName: z.string().optional(), - created: z.string().datetime(), - id: z.string(), - updated: z.string().datetime(), -}) - -/** - * Organization schema - */ -export const organizationSchema = baseRecordSchema.extend({ - address: z.string().optional().or(z.literal('')), - clerkId: z.string().optional().or(z.literal('')), - email: z.string().email().optional().or(z.literal('')), - name: z.string(), - phone: z.string().optional().or(z.literal('')), - priceId: z.string().optional().or(z.literal('')), - settings: z.record(z.string(), z.unknown()).optional().default({}), - stripeCustomerId: z.string().optional().or(z.literal('')), - subscriptionId: z.string().optional().or(z.literal('')), - subscriptionStatus: z.string().optional().or(z.literal('')), -}) - -/** - * AppUser schema - */ -export const appUserSchema = baseRecordSchema.extend({ - clerkId: z.string().optional().or(z.literal('')), - email: z.string().email().optional().or(z.literal('')), - emailVisibility: z.boolean().optional().default(true), - isAdmin: z.boolean().optional().default(false), - lastLogin: z.string().datetime().optional().or(z.literal('')), - metadata: z - .object({ - createdAt: z.number().optional(), - externalAccounts: z - .array( - z.object({ - email: z.string().email(), - imageUrl: z.string().url(), - provider: z.string(), - providerUserId: z.string(), - }) - ) - .optional(), - hasCompletedOnboarding: z.boolean().optional(), - lastActiveAt: z.number().optional(), - onboardingCompletedAt: z.string().optional(), - public: z - .object({ - hasCompletedOnboarding: z.boolean(), - onboardingCompletedAt: z.string(), - }) - .optional(), - updatedAt: z.number().optional(), - }) - .optional() - .default({}), - name: z.string().optional().or(z.literal('')), - organizations: z.string().optional().or(z.literal('')), - role: z.string().optional().or(z.literal('')), - verified: z.boolean().optional().default(false), -}) - -/** - * Equipment schema - */ -export const equipmentSchema = baseRecordSchema.extend({ - acquisitionDate: z.string().datetime().optional().or(z.literal('')), - name: z.string(), - notes: z.string().optional().or(z.literal('')), - organization: z.string(), - parentEquipment: z.string().optional().or(z.literal('')), - qrNfcCode: z.string(), - tags: z.string().optional().or(z.literal('')), -}) - -/** - * Project schema - */ -export const projectSchema = baseRecordSchema.extend({ - address: z.string().optional().or(z.literal('')), - endDate: z.string().datetime().optional().or(z.literal('')), - name: z.string(), - notes: z.string().optional().or(z.literal('')), - organization: z.string(), - startDate: z.string().datetime().optional().or(z.literal('')), -}) - -/** - * Assignment schema - */ -export const assignmentSchema = baseRecordSchema.extend({ - assignedToProject: z.string().optional().or(z.literal('')), - assignedToUser: z.string().optional().or(z.literal('')), - endDate: z.string().datetime().optional().or(z.literal('')), - equipment: z.string(), - notes: z.string().optional().or(z.literal('')), - organization: z.string(), - startDate: z.string().datetime(), -}) - -/** - * ActivityLog schema - */ -export const activityLogSchema = baseRecordSchema.extend({ - equipment: z.string().optional().or(z.literal('')), - metadata: z.record(z.string(), z.unknown()).optional().default({}), - organization: z.string().optional().or(z.literal('')), - user: z.string().optional().or(z.literal('')), -}) - -/** - * Image schema - */ -export const imageSchema = baseRecordSchema.extend({ - alt: z.string().optional().or(z.literal('')), - caption: z.string().optional().or(z.literal('')), - image: z.string(), - title: z.string().optional().or(z.literal('')), -}) - -/** - * List result schema (generic) - */ -export const listResultSchema = (itemSchema: T) => - z.object({ - items: z.array(itemSchema), - page: z.number(), - perPage: z.number(), - totalItems: z.number(), - totalPages: z.number(), - }) - -/** - * Query parameters schema - */ -export const queryParamsSchema = z.object({ - expand: z.string().optional(), - filter: z.string().optional(), - page: z.number().optional(), - perPage: z.number().optional(), - sort: z.string().optional(), -}) diff --git a/src/app/actions/services/pocketbase/api_client/types.ts b/src/app/actions/services/pocketbase/api_client/types.ts deleted file mode 100644 index a53d27a..0000000 --- a/src/app/actions/services/pocketbase/api_client/types.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Core types for PocketBase data models - * These types represent the schema of our database collections - */ - -/** - * Base type for all PocketBase records - */ -export interface BaseRecord { - id: string - created: string - updated: string - collectionId?: string - collectionName?: string -} - -/** - * Organization record - */ -export interface Organization extends BaseRecord { - name: string - email: string | '' - phone: string | '' - address: string | '' - settings: Record - clerkId: string - stripeCustomerId: string | '' - subscriptionId: string | '' - subscriptionStatus: string | '' - priceId: string | '' -} - -/** - * AppUser record - */ -export interface AppUser extends BaseRecord { - email: string | '' - emailVisibility: boolean - verified: boolean - name: string | '' - role: string | '' - isAdmin: boolean - lastLogin: string | '' - clerkId: string - organizations: string | '' - metadata: { - createdAt: number - externalAccounts?: Array<{ - email: string - imageUrl: string - provider: string - providerUserId: string - }> - hasCompletedOnboarding?: boolean - lastActiveAt?: number - onboardingCompletedAt?: string - public?: { - hasCompletedOnboarding: boolean - onboardingCompletedAt: string - } - updatedAt?: number - } -} - -/** - * Equipment record - */ -export interface Equipment extends BaseRecord { - organization: string - name: string - qrNfcCode: string - tags: string | '' - notes: string | '' - acquisitionDate: string | '' - parentEquipment?: string | '' -} - -/** - * Project record - */ -export interface Project extends BaseRecord { - name: string - address: string | '' - notes: string | '' - startDate: string | '' - endDate: string | '' - organization: string -} - -/** - * Assignment record - */ -export interface Assignment extends BaseRecord { - organization: string - equipment: string - assignedToUser?: string | '' - assignedToProject?: string | '' - startDate: string - endDate: string | '' - notes: string | '' -} - -/** - * ActivityLog record - */ -export interface ActivityLog extends BaseRecord { - organization?: string | '' - user?: string | '' - equipment?: string | '' - metadata: Record -} - -/** - * Image record - */ -export interface Image extends BaseRecord { - title: string | '' - alt: string | '' - caption: string | '' - image: string -} - -/** - * PocketBase response types - */ -export interface ListResult { - page: number - perPage: number - totalItems: number - totalPages: number - items: T[] -} - -/** - * Generic query parameters for list operations - */ -export interface QueryParams { - page?: number - perPage?: number - sort?: string - filter?: string - expand?: string -} diff --git a/src/app/actions/services/pocketbase/app_user_service.ts b/src/app/actions/services/pocketbase/app_user_service.ts index 7ed9d49..814b939 100644 --- a/src/app/actions/services/pocketbase/app_user_service.ts +++ b/src/app/actions/services/pocketbase/app_user_service.ts @@ -1,5 +1,6 @@ 'use server' +import { BaseService } from '@/app/actions/services/pocketbase/api_client' import { AppUser, AppUserCreateInput, @@ -10,8 +11,6 @@ import { appUserUpdateSchema, } from '@/models/pocketbase' -import { BaseService } from './api_client' - // Re-export types for convenience export type { AppUser, AppUserCreateInput, AppUserUpdateInput } @@ -24,9 +23,9 @@ export class AppUserService extends BaseService< AppUserUpdateInput > { constructor() { - // @ts-expect-error - Types are compatible but TypeScript cannot verify it super( Collections.APP_USERS, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] appUserSchema, appUserCreateSchema, appUserUpdateSchema diff --git a/src/app/actions/services/pocketbase/base_service_fix.ts b/src/app/actions/services/pocketbase/base_service_fix.ts deleted file mode 100644 index 8b1f8cd..0000000 --- a/src/app/actions/services/pocketbase/base_service_fix.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Utility function to fix type compatibility issues - * This function is a workaround for TypeScript type compatibility issues - * between Zod schemas and TypeScript interfaces - * - * @param schema The Zod schema to be fixed - * @returns The same schema with fixed type compatibility - */ -export function fixSchemaType(schema: any): any { - // Simply pass through the schema but with a different type signature - // This is a type assertion hack to make TypeScript happy - return schema -} diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts index fb6f3d2..4f91961 100644 --- a/src/app/actions/services/pocketbase/equipment_service.ts +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -1,5 +1,6 @@ 'use server' +import { BaseService } from '@/app/actions/services/pocketbase/api_client' import { Collections, Equipment, @@ -10,8 +11,6 @@ import { equipmentUpdateSchema, } from '@/models/pocketbase' -import { BaseService } from './api_client' - // Re-export types for convenience export type { Equipment, EquipmentCreateInput, EquipmentUpdateInput } @@ -27,7 +26,7 @@ export class EquipmentService extends BaseService< constructor() { super( Collections.EQUIPMENT, - // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] + // @eslint-disable-next-line @typescript-eslint/ban-ts-comment @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] equipmentSchema, equipmentCreateSchema, equipmentUpdateSchema diff --git a/src/app/actions/services/pocketbase/index.ts b/src/app/actions/services/pocketbase/index.ts index 6ef2c05..bef76c7 100644 --- a/src/app/actions/services/pocketbase/index.ts +++ b/src/app/actions/services/pocketbase/index.ts @@ -2,17 +2,15 @@ * PocketBase Services * Central export point for all PocketBase services and utilities */ +import { z } from 'zod' // Export API client core -export * from './api_client' +export * from '@/app/actions/services/pocketbase/api_client' // Export individual services -export * from './organization_service' -export * from './app_user_service' -export * from './equipment_service' - -// Re-export common validation utility -import { z } from 'zod' +export * from '@/app/actions/services/pocketbase/organization_service' +export * from '@/app/actions/services/pocketbase/app_user_service' +export * from '@/app/actions/services/pocketbase/equipment_service' /** * Validate organization ID format diff --git a/src/app/actions/services/pocketbase/organization_service.ts b/src/app/actions/services/pocketbase/organization_service.ts index 93fe25e..713f410 100644 --- a/src/app/actions/services/pocketbase/organization_service.ts +++ b/src/app/actions/services/pocketbase/organization_service.ts @@ -1,5 +1,6 @@ 'use server' +import { BaseService } from '@/app/actions/services/pocketbase/api_client' import { Collections, Organization, @@ -10,8 +11,6 @@ import { organizationUpdateSchema, } from '@/models/pocketbase' -import { BaseService } from './api_client' - // Re-export types for convenience export type { Organization, OrganizationCreateInput, OrganizationUpdateInput } diff --git a/src/app/api/webhook/clerk/admin/reconcile/route.ts b/src/app/api/webhook/clerk/admin/reconcile/route.ts index b3d1faa..36c6d0d 100644 --- a/src/app/api/webhook/clerk/admin/reconcile/route.ts +++ b/src/app/api/webhook/clerk/admin/reconcile/route.ts @@ -5,7 +5,6 @@ import { } from '@/app/actions/services/clerk-sync/reconciliation' import { auth } from '@clerk/nextjs/server' import { headers } from 'next/headers' -// src/app/api/admin/reconcile/route.ts import { NextRequest, NextResponse } from 'next/server' /** diff --git a/src/schemas/pocketbase/equipment.ts b/src/schemas/pocketbase/equipment.ts new file mode 100644 index 0000000..af9105a --- /dev/null +++ b/src/schemas/pocketbase/equipment.ts @@ -0,0 +1,58 @@ +import { + baseRecordSchema, + createServiceSchemas, + EquipmentCreateInput, + EquipmentUpdateInput, +} from '@/models/pocketbase' +import { z } from 'zod' + +/** + * Equipment schema for validation + * Defines validation rules for equipment records from PocketBase + */ +export const equipmentSchema = baseRecordSchema.extend({ + acquisitionDate: z.string(), + name: z.string(), + notes: z.string(), + organization: z.string(), + parentEquipment: z.string().optional(), + qrNfcCode: z.string(), + tags: z.string(), +}) + +/** + * Generated schemas for equipment CRUD operations + * We use the update schema but create a custom create schema + */ +const { updateSchema } = createServiceSchemas(equipmentSchema) + +/** + * Schema for equipment creation + * Use this for validating data when creating new equipment + */ +export const equipmentCreateSchema = z + .object({ + acquisitionDate: z.string().optional(), + name: z.string(), + notes: z.string().optional(), + organization: z.string(), + parentEquipment: z.string().optional(), + qrNfcCode: z.string(), + tags: z.string().optional(), + }) + .transform(data => { + // Ensure all string fields have default values + return { + ...data, + acquisitionDate: data.acquisitionDate || '', + notes: data.notes || '', + tags: data.tags || '', + } + }) as z.ZodType + +/** + * Schema for equipment updates + * Use this for validating data when updating equipment + */ +export const equipmentUpdateSchema = + updateSchema as z.ZodType From 15ed284df936cab6ead244d5d05af397ea2d05bd Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 14:37:30 +0200 Subject: [PATCH 51/73] fix(api): simplify authentication check function - Remove unnecessary request parameter from the function - Streamline code for better readability - Maintain existing functionality without changes --- src/app/api/webhook/clerk/admin/reconcile/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/webhook/clerk/admin/reconcile/route.ts b/src/app/api/webhook/clerk/admin/reconcile/route.ts index 36c6d0d..38eb324 100644 --- a/src/app/api/webhook/clerk/admin/reconcile/route.ts +++ b/src/app/api/webhook/clerk/admin/reconcile/route.ts @@ -63,7 +63,7 @@ export async function POST(req: NextRequest) { * @param req The incoming request * @returns Whether the request is authenticated */ -async function checkAuthentication(req: NextRequest): Promise { +async function checkAuthentication(): Promise { // Option 1: Check admin user const { orgRole, userId } = await auth() From 6422e48b0fb9c56620cf168e42e3005b94b281b7 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 14:44:22 +0200 Subject: [PATCH 52/73] chore(env): remove example environment file - Delete outdated env.example file - Clean up unused configuration placeholders - Streamline project setup process --- env.example | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 env.example diff --git a/env.example b/env.example deleted file mode 100644 index daed409..0000000 --- a/env.example +++ /dev/null @@ -1,12 +0,0 @@ -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=-- -CLERK_SECRET_KEY=-- -NEXT_PUBLIC_CLERK_SIGN_IN_URL=--/sign-in -NEXT_PUBLIC_CLERK_SIGN_UP_URL=--/sign-up -NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=--/app - -PB_TOKEN_API_ADMIN=--- -PB_API_URL=--- - -CLERK_WEBHOOK_SECRET_ORGANIZATION=-- -CLERK_WEBHOOK_SECRET_USER=-- -CLERK_WEBHOOK_SECRET_ORGANIZATION_MEMBERSHIP=-- \ No newline at end of file From b3b9f86731f14bce042bb83d639790aaddf2ba32 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 15:05:12 +0200 Subject: [PATCH 53/73] feat(docs): add comprehensive readme files for directories - Create detailed documentation for actions, services, models, components, hooks, and stores - Include best practices, do's and don'ts for each directory - Establish clear guidelines on structure and usage patterns - Enhance understanding of the application's architecture and functionality --- package.json | 3 + src/app/actions/equipment/readme.ai.md | 47 +++++++++++++++ src/app/actions/readme.ai.md | 47 +++++++++++++++ .../actions/services/clerk-sync/readme.ai.md | 51 ++++++++++++++++ .../pocketbase/api_client/readme.ai.md | 50 ++++++++++++++++ .../actions/services/pocketbase/readme.ai.md | 50 ++++++++++++++++ .../services/pocketbase/secured/readme.ai.md | 50 ++++++++++++++++ src/app/actions/services/readme.ai.md | 48 +++++++++++++++ src/app/readme.ai.md | 50 ++++++++++++++++ src/components/readme.ai.md | 50 ++++++++++++++++ src/hooks/readme.ai.md | 44 ++++++++++++++ src/lib/readme.ai.md | 44 ++++++++++++++ src/models/pocketbase/readme.ai.md | 54 +++++++++++++++++ src/models/readme.ai.md | 48 +++++++++++++++ src/readme.ai.md | 45 ++++++++++++++ src/schemas/pocketbase/equipment.ts | 58 ------------------- src/stores/readme.ai.md | 44 ++++++++++++++ 17 files changed, 725 insertions(+), 58 deletions(-) create mode 100644 src/app/actions/equipment/readme.ai.md create mode 100644 src/app/actions/readme.ai.md create mode 100644 src/app/actions/services/clerk-sync/readme.ai.md create mode 100644 src/app/actions/services/pocketbase/api_client/readme.ai.md create mode 100644 src/app/actions/services/pocketbase/readme.ai.md create mode 100644 src/app/actions/services/pocketbase/secured/readme.ai.md create mode 100644 src/app/actions/services/readme.ai.md create mode 100644 src/app/readme.ai.md create mode 100644 src/components/readme.ai.md create mode 100644 src/hooks/readme.ai.md create mode 100644 src/lib/readme.ai.md create mode 100644 src/models/pocketbase/readme.ai.md create mode 100644 src/models/readme.ai.md create mode 100644 src/readme.ai.md delete mode 100644 src/schemas/pocketbase/equipment.ts create mode 100644 src/stores/readme.ai.md diff --git a/package.json b/package.json index a1f49ba..3c325bf 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "start": "next start", "lint": "next lint", "lint:fix": "next lint --fix", + "eslint": "next lint --fix", + "prettier": "prettier --write .", + "prettier:check": "prettier --check .", "format": "prettier --write .", "format:check": "prettier --check .", "tsc": "npx tsc --noEmit --watch", diff --git a/src/app/actions/equipment/readme.ai.md b/src/app/actions/equipment/readme.ai.md new file mode 100644 index 0000000..f10687b --- /dev/null +++ b/src/app/actions/equipment/readme.ai.md @@ -0,0 +1,47 @@ +# Equipment Actions Overview + +This directory contains server actions specific to equipment management functionality. These actions provide the business logic for equipment-related operations in the application. + +## Key Files + +- Server action files for equipment operations (create, update, delete, etc.) + +## Key Concepts + +- **Server Actions**: Functions for equipment-related data operations +- **Validation**: Input validation for equipment operations +- **Security**: Authorization checks for equipment access +- **Business Logic**: Specific rules for equipment management + +## Best Practices + +- Use the secured PocketBase services for data operations +- Implement proper validation for all inputs +- Follow the established patterns for server actions +- Handle errors appropriately with specific messages + +## Do's and Don'ts + +### Do + +- Use secured services for all data operations +- Validate inputs with Zod before processing +- Handle errors with appropriate status codes +- Follow the established patterns for equipment actions + +### Don't + +- Bypass security checks or validation +- Implement business logic that belongs in the UI +- Create redundant actions for similar operations +- Expose sensitive data in responses + +## For AI Assistants + +When working with this directory: + +- Understand that these actions handle equipment-specific operations +- Note that all actions should use secured services +- Be aware of the business rules for equipment management +- Follow the established patterns for error handling +- Remember that equipment is organization-specific and requires proper isolation diff --git a/src/app/actions/readme.ai.md b/src/app/actions/readme.ai.md new file mode 100644 index 0000000..f4988b3 --- /dev/null +++ b/src/app/actions/readme.ai.md @@ -0,0 +1,47 @@ +# Actions Directory Overview + +This directory contains server actions that handle data operations and business logic for the application. Server actions are a Next.js feature that allows executing code on the server for secure data operations. + +## Directory Structure + +- `equipment/` - Actions related to equipment management +- `services/` - Core services and utilities for data access and integration + +## Key Concepts + +- **Server Actions**: Functions marked with `'use server'` that run securely on the server +- **Security Middleware**: Higher-order functions that wrap actions to enforce security +- **Service Layer**: Utilities for interacting with external services like PocketBase and Clerk + +## Best Practices + +- Always wrap sensitive operations with the security middleware +- Include proper error handling and validation +- Use TypeScript types for inputs and outputs +- Keep actions focused on a single responsibility + +## Do's and Don'ts + +### Do + +- Use the `withSecurity` middleware for protected operations +- Validate inputs with Zod before processing +- Handle errors with proper status codes and messages +- Organize actions by domain/feature + +### Don't + +- Create actions without proper security checks +- Expose sensitive data in return values +- Mix client and server code in the same file +- Bypass the service layer for data access + +## For AI Assistants + +When working with this directory: + +- Always respect the multi-tenant security model +- Understand the pattern of using service layers for data access +- Follow the established pattern for error handling and validation +- Be aware of the revalidation paths for data mutations +- Note that the security middleware automatically handles organization isolation diff --git a/src/app/actions/services/clerk-sync/readme.ai.md b/src/app/actions/services/clerk-sync/readme.ai.md new file mode 100644 index 0000000..560f0f3 --- /dev/null +++ b/src/app/actions/services/clerk-sync/readme.ai.md @@ -0,0 +1,51 @@ +# Clerk Synchronization Services Overview + +This directory contains services that handle synchronization between Clerk (authentication provider) and PocketBase (database). It ensures data consistency between these two systems. + +## Key Files + +- `syncService.ts` - Core functions for syncing users, organizations, and memberships +- `reconciliation.ts` - Functions for running full or partial data reconciliation +- `webhook-handler.ts` - Handler for processing Clerk webhook events +- `onBoardingHelper.ts` - Helpers for user onboarding processes +- `syncMiddleware.ts` - Middleware for sync operations + +## Key Concepts + +- **Data Synchronization**: Keeping Clerk and PocketBase data in sync +- **Webhooks**: Processing real-time events from Clerk +- **Reconciliation**: Scheduled processes to ensure data consistency +- **Onboarding**: Steps for new user and organization setup + +## Best Practices + +- Handle errors gracefully and provide detailed logs +- Use proper typing for all data structures +- Maintain idempotent operations for reliability +- Implement retries for transient failures + +## Do's and Don'ts + +### Do + +- Use the established sync functions for user and organization operations +- Handle webhook events according to their types +- Consider data consistency in all operations +- Log important sync operations for debugging + +### Don't + +- Create direct database updates that bypass sync services +- Remove error handling in sync operations +- Mix sync logic with presentation logic +- Introduce circular dependencies between sync functions + +## For AI Assistants + +When working with this directory: + +- Understand that `syncService.ts` contains the core sync functions +- Be aware of the data flow between Clerk and PocketBase +- Note that webhooks drive most synchronization operations +- Remember that reconciliation is used for periodic full sync +- Consider the direction of sync (Clerk to PocketBase, not vice versa) diff --git a/src/app/actions/services/pocketbase/api_client/readme.ai.md b/src/app/actions/services/pocketbase/api_client/readme.ai.md new file mode 100644 index 0000000..16ba9f8 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/readme.ai.md @@ -0,0 +1,50 @@ +# PocketBase API Client Overview + +This directory contains the core client for communicating with the PocketBase backend. It provides the foundation for all database operations. + +## Key Files + +- `client.ts` - Core PocketBase client initialization and utilities +- `base_service.ts` - Generic CRUD service for PocketBase collections +- `index.ts` - Exports for client components and services + +## Key Concepts + +- **PocketBase Client**: Initialized and configured connection to PocketBase +- **BaseService**: Generic class with CRUD operations for any collection +- **Error Handling**: Centralized error processing for PocketBase operations +- **Type Safety**: Zod validation for inputs and outputs + +## Best Practices + +- Use the BaseService for implementing entity-specific services +- Handle errors using the centralized error utilities +- Validate inputs and outputs using Zod schemas +- Follow the established patterns for service methods + +## Do's and Don'ts + +### Do + +- Use getPocketBase() to obtain the client instance +- Handle errors with handlePocketBaseError +- Validate data with validateWithZod +- Extend BaseService for new entity services + +### Don't + +- Create multiple PocketBase client instances +- Bypass error handling mechanisms +- Skip validation for inputs or outputs +- Modify core client behavior without careful consideration + +## For AI Assistants + +When working with this directory: + +- Understand that this is the foundational layer for database access +- Note that BaseService provides generic CRUD operations +- Be aware of the error handling patterns in handlePocketBaseError +- Recognize that all entity-specific services extend BaseService +- Remember that Zod is used for validation throughout the system +- Consider that this layer abstracts away direct PocketBase API calls diff --git a/src/app/actions/services/pocketbase/readme.ai.md b/src/app/actions/services/pocketbase/readme.ai.md new file mode 100644 index 0000000..eb43ccc --- /dev/null +++ b/src/app/actions/services/pocketbase/readme.ai.md @@ -0,0 +1,50 @@ +# PocketBase Services Overview + +This directory contains services for interacting with the PocketBase backend, our primary database. It provides structured access to data with proper validation and type safety. + +## Directory Structure + +- `api_client/` - Core client for PocketBase API communication +- `secured/` - Secured services with authentication and authorization checks +- `*_service.ts` files - Entity-specific services (app_user_service.ts, equipment_service.ts, etc.) + +## Key Concepts + +- **Service Pattern**: Entity-specific services for database operations +- **Base Service**: Generic CRUD operations shared across entities +- **Security Middleware**: Authentication and authorization checks +- **Type Safety**: Zod validation for inputs and outputs + +## Best Practices + +- Use entity-specific services for database operations +- Apply proper validation for all inputs and outputs +- Handle errors consistently with detailed messages +- Follow the established patterns for CRUD operations + +## Do's and Don'ts + +### Do + +- Use the established service pattern for new entities +- Follow the type validation approach with Zod +- Implement proper error handling +- Use the security middleware for protected operations + +### Don't + +- Create direct PocketBase calls outside the service layer +- Skip validation for inputs or outputs +- Mix authorization logic with data access +- Duplicate functionality across services + +## For AI Assistants + +When working with this directory: + +- Understand the service pattern for database access +- Note that entity-specific services extend the BaseService +- Be aware of the security middleware for protected operations +- Follow the established error handling patterns +- Remember that Zod schemas are used for validation +- Recognize that the api_client subdirectory contains the core client code diff --git a/src/app/actions/services/pocketbase/secured/readme.ai.md b/src/app/actions/services/pocketbase/secured/readme.ai.md new file mode 100644 index 0000000..c406c34 --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/readme.ai.md @@ -0,0 +1,50 @@ +# Secured PocketBase Services Overview + +This directory contains secured versions of PocketBase services that implement authentication, authorization, and multi-tenant isolation. These services form the foundation of the application's security model. + +## Key Files + +- `security_middleware.ts` - Core middleware for securing server actions +- `equipment_service.ts` - Secured equipment service with authorization checks +- Other entity-specific secured services + +## Key Concepts + +- **Security Middleware**: withSecurity() HOF that wraps server actions +- **SecurityContext**: Context object with user and organization information +- **Authorization Checks**: Permission validation for operations +- **Multi-tenancy**: Organization-based data isolation + +## Best Practices + +- Always use withSecurity() for protected operations +- Implement proper permission checks for each operation +- Return appropriate error responses for security violations +- Follow the established patterns for secured services + +## Do's and Don'ts + +### Do + +- Use the withSecurity middleware for all protected actions +- Check permissions before data operations +- Return SecurityError for authorization failures +- Respect organization boundaries for data access + +### Don't + +- Create server actions without proper security checks +- Bypass the security middleware +- Allow cross-organization data access +- Expose sensitive operations without authentication + +## For AI Assistants + +When working with this directory: + +- Understand the central role of the security middleware +- Note that all secured services use withSecurity() +- Be aware of the SecurityContext object passed to actions +- Recognize the importance of checkResourcePermission() +- Remember that organization isolation is a core security principle +- Follow the established patterns for new secured services diff --git a/src/app/actions/services/readme.ai.md b/src/app/actions/services/readme.ai.md new file mode 100644 index 0000000..6ba3a12 --- /dev/null +++ b/src/app/actions/services/readme.ai.md @@ -0,0 +1,48 @@ +# Services Directory Overview + +This directory contains service modules that handle integration with external systems and provide a clean API for data access. Services act as a bridge between server actions and external resources. + +## Directory Structure + +- `clerk-sync/` - Services for synchronizing data between Clerk and PocketBase +- `pocketbase/` - Services for interacting with the PocketBase backend +- `securyUtilsTools.ts` - Security utilities for server actions + +## Key Concepts + +- **Service Layer**: Encapsulates external system interactions +- **Data Synchronization**: Maintains consistency between auth system and database +- **Security Middleware**: Enforces access control and tenant isolation + +## Best Practices + +- Keep service methods focused on single responsibilities +- Handle errors consistently and provide meaningful messages +- Use TypeScript types for service inputs and outputs +- Follow the established patterns for each service type + +## Do's and Don'ts + +### Do + +- Use the appropriate service for each external system +- Handle and log errors at the service level +- Follow the established typings for each service +- Maintain proper separation between different services + +### Don't + +- Mix concerns between different service types +- Bypass service layers to access external systems directly +- Expose sensitive information in service responses +- Create redundant services for the same functionality + +## For AI Assistants + +When working with this directory: + +- Understand that services are organized by external system +- Be aware of the synchronization patterns between Clerk and PocketBase +- Note the security patterns implemented in the middleware +- Follow the established error handling patterns +- Remember that PocketBase services form the core data access layer diff --git a/src/app/readme.ai.md b/src/app/readme.ai.md new file mode 100644 index 0000000..c968b5e --- /dev/null +++ b/src/app/readme.ai.md @@ -0,0 +1,50 @@ +# App Directory Overview + +This directory follows the Next.js App Router structure and contains the main application routes, page components, and server actions. + +## Directory Structure + +- `(application)/` - Protected routes requiring authentication +- `(marketing)/` - Public routes for marketing and public pages +- `actions/` - Server actions for data operations +- `api/` - API routes including webhooks +- `globals.css` - Global CSS styles + +## Key Concepts + +- Routes are organized by access level using route groups (`(application)` and `(marketing)`) +- Server components are used by default for better performance and security +- Server actions handle data mutations with proper authorization checks +- API routes handle webhooks and external integrations + +## Best Practices + +- Keep page components lightweight and focused on layout +- Move complex logic to server actions +- Use client components only when interactivity is needed +- Follow the security patterns established for data access + +## Do's and Don'ts + +### Do + +- Use server components by default +- Add authorization checks to all secure routes and actions +- Handle errors appropriately in server actions +- Use the established middleware for security enforcement + +### Don't + +- Expose sensitive data in client components +- Bypass the security middleware for protected operations +- Create duplicative API routes for similar functionality +- Add heavy logic to page components + +## For AI Assistants + +When working with this directory: + +- Always respect the division between public and protected routes +- Ensure new server actions use the security middleware +- Be aware that the app uses organization-based multi-tenancy +- Follow the file naming conventions established in each subdirectory diff --git a/src/components/readme.ai.md b/src/components/readme.ai.md new file mode 100644 index 0000000..67154e5 --- /dev/null +++ b/src/components/readme.ai.md @@ -0,0 +1,50 @@ +# Components Directory Overview + +This directory contains all the React components used throughout the application. It's organized to promote reusability, maintainability, and a consistent user interface. + +## Directory Structure + +- `app/` - Application-specific components +- `magicui/` - Advanced UI components with animations and effects +- `ui/` - Basic UI components built on shadcn/ui + +## Key Concepts + +- **Component Hierarchy**: Organized from basic UI elements to complex compositions +- **Reusability**: Components designed for reuse across the application +- **Client vs Server Components**: Separation based on interactivity needs +- **UI Consistency**: Common design language across components + +## Best Practices + +- Keep components focused on a single responsibility +- Use TypeScript props interfaces for all components +- Separate client and server components appropriately +- Follow the established design patterns and aesthetic + +## Do's and Don'ts + +### Do + +- Use existing UI components whenever possible +- Add proper TypeScript types for component props +- Write JSDoc comments for complex components +- Keep client components lightweight + +### Don't + +- Create duplicate components with similar functionality +- Mix client and server code inappropriately +- Add business logic to UI components +- Create overly complex components that could be composed + +## For AI Assistants + +When working with this directory: + +- Understand the distinction between ui, magicui, and app components +- Note that 'use client' directive marks client components +- Be aware of the shadcn/ui patterns and conventions +- Remember to use Tailwind CSS for styling +- Follow the established naming conventions +- Recognize that components should be composable and reusable diff --git a/src/hooks/readme.ai.md b/src/hooks/readme.ai.md new file mode 100644 index 0000000..b0a96dc --- /dev/null +++ b/src/hooks/readme.ai.md @@ -0,0 +1,44 @@ +# Hooks Directory Overview + +This directory contains custom React hooks that encapsulate reusable logic across the application. These hooks help separate concerns and make component code cleaner and more maintainable. + +## Key Concepts + +- **Custom Hooks**: Reusable functions that use React's built-in hooks +- **Shared Logic**: Common patterns extracted into reusable units +- **State Management**: Local state management separated from components +- **Side Effects**: Encapsulated API calls and other effects + +## Best Practices + +- Keep hooks focused on a single responsibility +- Use TypeScript for proper typing of inputs and outputs +- Follow the React hooks naming convention (use\*) +- Add comprehensive JSDoc comments + +## Do's and Don'ts + +### Do + +- Create hooks for repeated logic patterns +- Use TypeScript generics for flexible, reusable hooks +- Handle errors appropriately within hooks +- Test hooks independently of components + +### Don't + +- Create hooks that mix unrelated concerns +- Put UI rendering logic in hooks +- Create hooks with side effects that can't be cleaned up +- Duplicate functionality that exists in standard React hooks + +## For AI Assistants + +When working with this directory: + +- Understand that hooks encapsulate reusable logic, not UI +- Note that hooks should follow React's rules of hooks +- Be aware of the naming convention (use\* prefix) +- Remember that hooks should have proper TypeScript types +- Follow the established patterns for error handling +- Consider that hooks may use Zustand stores for global state diff --git a/src/lib/readme.ai.md b/src/lib/readme.ai.md new file mode 100644 index 0000000..2d019a6 --- /dev/null +++ b/src/lib/readme.ai.md @@ -0,0 +1,44 @@ +# Library Directory Overview + +This directory contains utility functions, helper classes, and shared code that is used throughout the application. It provides common functionality that doesn't fit into components, hooks, or models. + +## Key Concepts + +- **Utility Functions**: Pure functions for common tasks +- **Helpers**: Helper functions for specific domains +- **Constants**: Application-wide constants and configuration +- **Type Guards**: TypeScript type guards and type utilities + +## Best Practices + +- Keep utilities focused on a single responsibility +- Write pure functions whenever possible +- Use TypeScript for proper typing +- Add comprehensive tests for utility functions + +## Do's and Don'ts + +### Do + +- Create well-documented, reusable utilities +- Use descriptive names that indicate purpose +- Add proper TypeScript types for all functions +- Group related utilities in appropriate files + +### Don't + +- Add stateful logic to utility functions +- Create utilities with side effects +- Duplicate functionality from standard libraries +- Mix unrelated utilities in the same file + +## For AI Assistants + +When working with this directory: + +- Understand that lib contains shareable, stateless utilities +- Note that functions should be pure when possible +- Be aware of the organization by domain/purpose +- Remember that utilities should have proper TypeScript types +- Follow the established patterns for error handling +- Consider that utilities should be well-tested diff --git a/src/models/pocketbase/readme.ai.md b/src/models/pocketbase/readme.ai.md new file mode 100644 index 0000000..0dc1a20 --- /dev/null +++ b/src/models/pocketbase/readme.ai.md @@ -0,0 +1,54 @@ +# PocketBase Models Overview + +This directory contains the models, types, and validation schemas specific to PocketBase entities. These models represent the database schema and provide type safety and validation. + +## Key Files + +- `base.model.ts` - Base model types and utilities for all PocketBase entities +- `collections.model.ts` - Collection name constants +- Entity-specific models: + - `app-user.model.ts` - User model + - `equipment.model.ts` - Equipment model + - `organization.model.ts` - Organization model +- `index.ts` - Centralized exports for all models + +## Key Concepts + +- **Base Models**: Common fields shared by all PocketBase records +- **Entity Models**: Specific fields and validation for each entity type +- **Zod Schemas**: Runtime validation for data integrity +- **TypeScript Types**: Static typing for development safety + +## Best Practices + +- Define both TypeScript types and Zod schemas for each entity +- Follow the established naming conventions (EntityName, EntityNameCreateInput, etc.) +- Use the createServiceSchemas utility for consistent schema creation +- Maintain backward compatibility when modifying existing models + +## Do's and Don'ts + +### Do + +- Create new entity models following the established pattern +- Export all types and schemas from the index.ts file +- Add JSDoc comments for complex fields or validation rules +- Use the base model types for common fields + +### Don't + +- Duplicate type definitions across files +- Add business logic to model files +- Create models without proper validation schemas +- Define entity-specific fields in the base model + +## For AI Assistants + +When working with this directory: + +- Understand the pattern of types + schemas for each entity +- Note that each entity model has create/update input types +- Be aware of the relationship between models and PocketBase collections +- Remember that all entities extend the BaseRecord type +- Follow the established naming conventions for consistency +- Recognize that models are used by services for data operations diff --git a/src/models/readme.ai.md b/src/models/readme.ai.md new file mode 100644 index 0000000..a4df186 --- /dev/null +++ b/src/models/readme.ai.md @@ -0,0 +1,48 @@ +# Models Directory Overview + +This directory contains the data models, type definitions, and validation schemas used throughout the application. It serves as the central source of truth for data structures. + +## Directory Structure + +- `pocketbase/` - Models for PocketBase entities with type definitions and Zod schemas + +## Key Concepts + +- **Type Definitions**: TypeScript interfaces for all data structures +- **Validation Schemas**: Zod schemas for runtime validation +- **Data Models**: Combination of types and validation for entities +- **Single Source of Truth**: Central location for all data structures + +## Best Practices + +- Define all types and schemas in one place +- Use Zod for validation throughout the application +- Follow the established patterns for model definitions +- Keep models focused on data structure, not behavior + +## Do's and Don'ts + +### Do + +- Define new models in the appropriate subdirectory +- Create both TypeScript types and Zod schemas +- Export types and schemas from the index files +- Follow the established naming conventions + +### Don't + +- Duplicate model definitions across the application +- Mix business logic with data models +- Create models without proper validation schemas +- Define models outside this directory + +## For AI Assistants + +When working with this directory: + +- Understand that models define both types and validation +- Note that all PocketBase models extend BaseRecord +- Be aware of the centralized export pattern in index.ts files +- Remember that these models are used throughout the application +- Follow the established patterns for new model definitions +- Recognize that models are organized by data source/system diff --git a/src/readme.ai.md b/src/readme.ai.md new file mode 100644 index 0000000..65057fe --- /dev/null +++ b/src/readme.ai.md @@ -0,0 +1,45 @@ +# Source Directory Overview + +This directory contains the main source code for the SaaS Platform for Equipment Management with NFC/QR tracking. + +## Directory Structure + +- `app/` - Next.js App Router structure with application routes and server actions +- `components/` - Reusable React components +- `hooks/` - Custom React hooks for shared logic +- `lib/` - Utility functions and shared code +- `models/` - Data models and type definitions +- `stores/` - Zustand state management stores + +## Best Practices + +- Follow the established architectural patterns for each directory +- Maintain proper separation of concerns between layers +- Keep components focused on UI and presentation logic +- Use server actions for data mutations and secure operations +- Leverage the models directory for shared type definitions + +## Do's and Don'ts + +### Do + +- Use relative imports with the `@/` prefix (e.g., `import { Button } from '@/components/ui/button'`) +- Add proper TypeScript type definitions for all new code +- Place business logic in appropriate server actions +- Reuse existing components and hooks where possible + +### Don't + +- Add business logic to client components +- Create circular dependencies between modules +- Duplicate code that already exists elsewhere +- Use any/unknown types without proper typing + +## For AI Assistants + +When working with this codebase: + +- Understand the multi-tenant architecture with organization isolation +- Respect the security model with proper authentication checks +- Use the service layer for data access operations +- Follow the established directory structure and naming conventions diff --git a/src/schemas/pocketbase/equipment.ts b/src/schemas/pocketbase/equipment.ts deleted file mode 100644 index af9105a..0000000 --- a/src/schemas/pocketbase/equipment.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - baseRecordSchema, - createServiceSchemas, - EquipmentCreateInput, - EquipmentUpdateInput, -} from '@/models/pocketbase' -import { z } from 'zod' - -/** - * Equipment schema for validation - * Defines validation rules for equipment records from PocketBase - */ -export const equipmentSchema = baseRecordSchema.extend({ - acquisitionDate: z.string(), - name: z.string(), - notes: z.string(), - organization: z.string(), - parentEquipment: z.string().optional(), - qrNfcCode: z.string(), - tags: z.string(), -}) - -/** - * Generated schemas for equipment CRUD operations - * We use the update schema but create a custom create schema - */ -const { updateSchema } = createServiceSchemas(equipmentSchema) - -/** - * Schema for equipment creation - * Use this for validating data when creating new equipment - */ -export const equipmentCreateSchema = z - .object({ - acquisitionDate: z.string().optional(), - name: z.string(), - notes: z.string().optional(), - organization: z.string(), - parentEquipment: z.string().optional(), - qrNfcCode: z.string(), - tags: z.string().optional(), - }) - .transform(data => { - // Ensure all string fields have default values - return { - ...data, - acquisitionDate: data.acquisitionDate || '', - notes: data.notes || '', - tags: data.tags || '', - } - }) as z.ZodType - -/** - * Schema for equipment updates - * Use this for validating data when updating equipment - */ -export const equipmentUpdateSchema = - updateSchema as z.ZodType diff --git a/src/stores/readme.ai.md b/src/stores/readme.ai.md new file mode 100644 index 0000000..4a90248 --- /dev/null +++ b/src/stores/readme.ai.md @@ -0,0 +1,44 @@ +# Stores Directory Overview + +This directory contains Zustand stores for global state management across the application. These stores provide a lightweight alternative to React Context for sharing state between components. + +## Key Concepts + +- **Zustand Stores**: Lightweight state management with hooks +- **Global State**: Shared application state across components +- **Persistence**: Optionally persisted state using middleware +- **Type Safety**: TypeScript interfaces for store state and actions + +## Best Practices + +- Keep stores focused on specific domains +- Use TypeScript interfaces for store state and actions +- Follow the established patterns for store creation +- Separate actions from state in the store definition + +## Do's and Don'ts + +### Do + +- Create domain-specific stores with clear boundaries +- Use selectors for accessing specific parts of state +- Add TypeScript interfaces for store state +- Document complex state structures or actions + +### Don't + +- Create overlapping stores with duplicate state +- Put transient UI state in global stores +- Create stores with overly complex state shapes +- Bypass stores for cross-component communication + +## For AI Assistants + +When working with this directory: + +- Understand that Zustand is the preferred state management solution +- Note that stores expose both state and actions +- Be aware of the convention to use selectors for state access +- Remember that stores can use middleware for persistence +- Follow the established patterns for new stores +- Consider that stores should be used where React props would be cumbersome From 5d2c06965f6c87f70db362e0ba8c58df99d83de2 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 16:12:43 +0200 Subject: [PATCH 54/73] feat(docs): update directory readme files - Clarify routing structure for application and marketing - Add note about future library in components section - Emphasize best practices for component creation and usage - Highlight Zustand stores over React context for state management - Specify TypeScript type definitions location in main source code --- src/app/readme.ai.md | 2 ++ src/components/readme.ai.md | 22 +++------------------- src/hooks/readme.ai.md | 1 - src/readme.ai.md | 2 +- src/stores/readme.ai.md | 3 ++- 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/app/readme.ai.md b/src/app/readme.ai.md index c968b5e..3408cbd 100644 --- a/src/app/readme.ai.md +++ b/src/app/readme.ai.md @@ -13,6 +13,8 @@ This directory follows the Next.js App Router structure and contains the main ap ## Key Concepts - Routes are organized by access level using route groups (`(application)` and `(marketing)`) +- (application) is used for the main app part (/app(\*)) +- (marketing) is used only for the front end marketing / business part (/(\*)) - Server components are used by default for better performance and security - Server actions handle data mutations with proper authorization checks - API routes handle webhooks and external integrations diff --git a/src/components/readme.ai.md b/src/components/readme.ai.md index 67154e5..3bd8f26 100644 --- a/src/components/readme.ai.md +++ b/src/components/readme.ai.md @@ -7,6 +7,7 @@ This directory contains all the React components used throughout the application - `app/` - Application-specific components - `magicui/` - Advanced UI components with animations and effects - `ui/` - Basic UI components built on shadcn/ui +- `otherslib.../` - Future lib used in the app, template etc. ## Key Concepts @@ -15,36 +16,19 @@ This directory contains all the React components used throughout the application - **Client vs Server Components**: Separation based on interactivity needs - **UI Consistency**: Common design language across components -## Best Practices - -- Keep components focused on a single responsibility -- Use TypeScript props interfaces for all components -- Separate client and server components appropriately -- Follow the established design patterns and aesthetic - ## Do's and Don'ts ### Do -- Use existing UI components whenever possible -- Add proper TypeScript types for component props -- Write JSDoc comments for complex components -- Keep client components lightweight +- do not create them, we need to create / modify these elements only throught the npx commands with ShadCn ### Don't - Create duplicate components with similar functionality - Mix client and server code inappropriately -- Add business logic to UI components -- Create overly complex components that could be composed ## For AI Assistants When working with this directory: -- Understand the distinction between ui, magicui, and app components -- Note that 'use client' directive marks client components -- Be aware of the shadcn/ui patterns and conventions -- Remember to use Tailwind CSS for styling -- Follow the established naming conventions -- Recognize that components should be composable and reusable +- do not create them, we need to create / modify these elements only throught the npx commands with ShadCn diff --git a/src/hooks/readme.ai.md b/src/hooks/readme.ai.md index b0a96dc..24f9a88 100644 --- a/src/hooks/readme.ai.md +++ b/src/hooks/readme.ai.md @@ -38,7 +38,6 @@ When working with this directory: - Understand that hooks encapsulate reusable logic, not UI - Note that hooks should follow React's rules of hooks -- Be aware of the naming convention (use\* prefix) - Remember that hooks should have proper TypeScript types - Follow the established patterns for error handling - Consider that hooks may use Zustand stores for global state diff --git a/src/readme.ai.md b/src/readme.ai.md index 65057fe..b9515fd 100644 --- a/src/readme.ai.md +++ b/src/readme.ai.md @@ -24,7 +24,7 @@ This directory contains the main source code for the SaaS Platform for Equipment ### Do - Use relative imports with the `@/` prefix (e.g., `import { Button } from '@/components/ui/button'`) -- Add proper TypeScript type definitions for all new code +- Add proper TypeScript type definitions for all new code, in the models/types folder - Place business logic in appropriate server actions - Reuse existing components and hooks where possible diff --git a/src/stores/readme.ai.md b/src/stores/readme.ai.md index 4a90248..2b629d3 100644 --- a/src/stores/readme.ai.md +++ b/src/stores/readme.ai.md @@ -1,6 +1,7 @@ # Stores Directory Overview This directory contains Zustand stores for global state management across the application. These stores provide a lightweight alternative to React Context for sharing state between components. +We need to avoid all the React context use. so we use Zustand stores when it's needed (we avoid props drilling with this strategie) ## Key Concepts @@ -12,7 +13,7 @@ This directory contains Zustand stores for global state management across the ap ## Best Practices - Keep stores focused on specific domains -- Use TypeScript interfaces for store state and actions +- Use TypeScript interfaces for store state and actions, avoid duplicate with types already defined in other layers ( models / types ) - Follow the established patterns for store creation - Separate actions from state in the store definition From 6242c8e2b3b65f7e08acee28d4c9420b2903ce63 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 17:19:18 +0200 Subject: [PATCH 55/73] feat(db): update user metadata and date handling - Change date fields in user metadata from number to string - Normalize datetime strings for consistency across records - Replace admin authentication method with token-based approach - Update collection names for better clarity and consistency - Remove unused isAdmin field from user model --- .../services/clerk-sync/syncService.ts | 9 ++- .../services/pocketbase/api_client/client.ts | 20 ++--- src/models/pocketbase/app-user.model.ts | 75 +++++++++---------- src/models/pocketbase/base.model.ts | 21 +++++- src/models/pocketbase/collections.model.ts | 14 ++-- 5 files changed, 72 insertions(+), 67 deletions(-) diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index b07c1de..f3e3226 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -65,12 +65,11 @@ export async function syncUserToPocketBase(clerkUser: User): Promise { const userData = { email: primaryEmail.emailAddress, emailVisibility: true, - isAdmin: clerkUser.publicMetadata?.isAdmin === true, lastLogin: clerkUser.lastSignInAt ? new Date(clerkUser.lastSignInAt).toISOString() : '', metadata: { - createdAt: clerkUser.createdAt, + createdAt: clerkUser.createdAt.toString(), externalAccounts: clerkUser.externalAccounts?.map(account => ({ email: account.emailAddress || '', @@ -80,7 +79,9 @@ export async function syncUserToPocketBase(clerkUser: User): Promise { })) || [], hasCompletedOnboarding: clerkUser.publicMetadata?.hasCompletedOnboarding === true, - lastActiveAt: clerkUser.lastActiveAt, + lastActiveAt: clerkUser.lastActiveAt + ? clerkUser.lastActiveAt.toString() + : '', onboardingCompletedAt: clerkUser.publicMetadata ?.onboardingCompletedAt as string, public: { @@ -89,7 +90,7 @@ export async function syncUserToPocketBase(clerkUser: User): Promise { onboardingCompletedAt: clerkUser.publicMetadata ?.onboardingCompletedAt as string, }, - updatedAt: clerkUser.updatedAt, + updatedAt: clerkUser.updatedAt.toString(), }, name: `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim() || diff --git a/src/app/actions/services/pocketbase/api_client/client.ts b/src/app/actions/services/pocketbase/api_client/client.ts index fce042a..98bc5a5 100644 --- a/src/app/actions/services/pocketbase/api_client/client.ts +++ b/src/app/actions/services/pocketbase/api_client/client.ts @@ -37,20 +37,11 @@ export const getPocketBase = cache(() => { // Create a new PocketBase instance const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL) - // In a server context, we'll need to use an admin auth - // We don't use cookie auth on the server side to avoid issues with next/headers cookies - if ( - process.env.POCKETBASE_ADMIN_EMAIL && - process.env.POCKETBASE_ADMIN_PASSWORD - ) { - pb.admins - .authWithPassword( - process.env.POCKETBASE_ADMIN_EMAIL, - process.env.POCKETBASE_ADMIN_PASSWORD - ) - .catch(error => { - console.error('Failed to authenticate with PocketBase admin:', error) - }) + const token = process.env.PB_TOKEN_API_ADMIN + if (token) { + pb.authStore.save(token) + } else { + throw new Error('PB_TOKEN_API_ADMIN is not set') } return pb @@ -94,6 +85,7 @@ export type CollectionName = (typeof Collections)[keyof typeof Collections] */ export function validateWithZod(schema: z.ZodType, data: unknown): T { try { + console.log('data', data) return schema.parse(data) } catch (error) { if (error instanceof z.ZodError) { diff --git a/src/models/pocketbase/app-user.model.ts b/src/models/pocketbase/app-user.model.ts index 5d9a3f3..15d384a 100644 --- a/src/models/pocketbase/app-user.model.ts +++ b/src/models/pocketbase/app-user.model.ts @@ -2,6 +2,7 @@ import { BaseRecord, baseRecordSchema, createServiceSchemas, + normalizeDateTime, } from '@/models/pocketbase/base.model' import { z } from 'zod' @@ -20,12 +21,11 @@ export interface AppUser extends BaseRecord { verified: boolean name: string role: string - isAdmin: boolean lastLogin: string clerkId: string organizations: string metadata: { - createdAt: number + createdAt: string externalAccounts?: Array<{ email: string imageUrl: string @@ -33,13 +33,13 @@ export interface AppUser extends BaseRecord { providerUserId: string }> hasCompletedOnboarding?: boolean - lastActiveAt?: number + lastActiveAt?: string onboardingCompletedAt?: string public?: { hasCompletedOnboarding: boolean onboardingCompletedAt: string } - updatedAt?: number + updatedAt?: string } } @@ -52,12 +52,11 @@ export interface AppUserCreateInput { verified?: boolean name?: string role?: string - isAdmin?: boolean lastLogin?: string clerkId: string organizations?: string metadata?: { - createdAt: number + createdAt: string externalAccounts?: Array<{ email: string imageUrl: string @@ -65,13 +64,13 @@ export interface AppUserCreateInput { providerUserId: string }> hasCompletedOnboarding?: boolean - lastActiveAt?: number + lastActiveAt?: string onboardingCompletedAt?: string public?: { hasCompletedOnboarding: boolean onboardingCompletedAt: string } - updatedAt?: number + updatedAt?: string } } @@ -86,39 +85,34 @@ export type AppUserUpdateInput = Partial * ======================================== */ -/** - * External account schema (for metadata) - */ -const externalAccountSchema = z.object({ - email: z.string().email(), - imageUrl: z.string().url(), - provider: z.string(), - providerUserId: z.string(), -}) - -/** - * Metadata public schema (for metadata) - */ -const metadataPublicSchema = z.object({ - hasCompletedOnboarding: z.boolean(), - onboardingCompletedAt: z.string(), -}) - /** * Metadata schema for app user */ -const metadataSchema = z +const appUserMetadataSchema = z .object({ - createdAt: z.number().optional(), - externalAccounts: z.array(externalAccountSchema).optional(), + createdAt: z.union([z.number(), z.string()]), + externalAccounts: z + .array( + z.object({ + email: z.string().optional(), + imageUrl: z.string().optional(), + provider: z.string(), + providerUserId: z.string(), + }) + ) + .optional(), hasCompletedOnboarding: z.boolean().optional(), - lastActiveAt: z.number().optional(), + lastActiveAt: z.union([z.number(), z.string()]), onboardingCompletedAt: z.string().optional(), - public: metadataPublicSchema.optional(), - updatedAt: z.number().optional(), + public: z + .object({ + hasCompletedOnboarding: z.boolean().optional(), + onboardingCompletedAt: z.string().optional(), + }) + .optional(), + updatedAt: z.union([z.number(), z.string()]), }) - .optional() - .default({}) + .passthrough() /** * AppUser schema for validation @@ -127,9 +121,15 @@ export const appUserSchema = baseRecordSchema.extend({ clerkId: z.string(), email: z.string().email(), emailVisibility: z.boolean().default(true), - isAdmin: z.boolean().default(false), - lastLogin: z.string().optional().or(z.literal('')), - metadata: metadataSchema, + lastLogin: z + .string() + .optional() + .or(z.literal('')) + .transform((val: string | undefined | '') => { + if (!val) return '' + return normalizeDateTime(val) + }), + metadata: appUserMetadataSchema, name: z.string().optional().or(z.literal('')), organizations: z.string().optional().or(z.literal('')), role: z.string().optional().or(z.literal('')), @@ -149,7 +149,6 @@ export const appUserCreateSchema = createSchema.transform(data => { return { ...data, emailVisibility: data.emailVisibility ?? true, - isAdmin: data.isAdmin ?? false, lastLogin: data.lastLogin || '', metadata: data.metadata || { createdAt: Date.now() }, name: data.name || '', diff --git a/src/models/pocketbase/base.model.ts b/src/models/pocketbase/base.model.ts index 08c463a..74d0260 100644 --- a/src/models/pocketbase/base.model.ts +++ b/src/models/pocketbase/base.model.ts @@ -47,15 +47,28 @@ export interface QueryParams { */ /** - * Base schema for all PocketBase records - * Contains validation for system fields present in all records + * Normalizes a datetime string to ISO format by replacing space with 'T' + */ +export function normalizeDateTime(dateString: string): string { + if (!dateString) return '' + + // Replace space with 'T' to ensure ISO 8601 format + if (dateString.includes(' ') && dateString.includes('Z')) { + return dateString.replace(' ', 'T') + } + + return dateString +} + +/** + * Base record schema with common PocketBase fields */ export const baseRecordSchema = z.object({ collectionId: z.string().optional(), collectionName: z.string().optional(), - created: z.string().datetime(), + created: z.string().transform(normalizeDateTime), id: z.string(), - updated: z.string().datetime(), + updated: z.string().transform(normalizeDateTime), }) /** diff --git a/src/models/pocketbase/collections.model.ts b/src/models/pocketbase/collections.model.ts index 1c0af0f..ece0dce 100644 --- a/src/models/pocketbase/collections.model.ts +++ b/src/models/pocketbase/collections.model.ts @@ -3,11 +3,11 @@ * Centralizing these constants prevents typos and makes refactoring easier */ export enum Collections { - ACTIVITY_LOGS = 'activity_logs', - APP_USERS = 'app_users', - ASSIGNMENTS = 'assignments', - EQUIPMENT = 'equipment', - IMAGES = 'images', - ORGANIZATIONS = 'organizations', - PROJECTS = 'projects', + ACTIVITY_LOGS = 'ActivityLog', + APP_USERS = 'AppUser', + ASSIGNMENTS = 'Assignment', + EQUIPMENT = 'Equipment', + IMAGES = 'Image', + ORGANIZATIONS = 'Organization', + PROJECTS = 'Project', } From f4b996e0e2a2536bbbc42cde398201c9192d372c Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 18:28:35 +0200 Subject: [PATCH 56/73] feat(webhook): enhance webhook processing and validation - Add detailed error handling for invalid events - Implement checks for missing organization and user IDs - Use parsed payload from verification for better clarity - Update logging to provide more informative messages - Introduce unique index creation in schema definition --- .cursor/md/example-export-pb-schema.md | 13 +- .../services/clerk-sync/webhook-handler.ts | 176 ++++++++++++++++-- .../services/pocketbase/api_client/client.ts | 2 +- .../services/pocketbase/app_user_service.ts | 2 - .../services/pocketbase/equipment_service.ts | 2 - .../pocketbase/organization_service.ts | 2 - .../clerk/organization-membership/route.ts | 13 +- .../api/webhook/clerk/organization/route.ts | 11 +- src/app/api/webhook/clerk/user/route.ts | 11 +- 9 files changed, 178 insertions(+), 54 deletions(-) diff --git a/.cursor/md/example-export-pb-schema.md b/.cursor/md/example-export-pb-schema.md index eca3d55..4393435 100644 --- a/.cursor/md/example-export-pb-schema.md +++ b/.cursor/md/example-export-pb-schema.md @@ -183,15 +183,6 @@ "system": false, "type": "text" }, - { - "hidden": false, - "id": "bool2165931080", - "name": "isAdmin", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }, { "hidden": false, "id": "date2697416787", @@ -261,7 +252,9 @@ "type": "autodate" } ], - "indexes": [], + "indexes": [ + "CREATE UNIQUE INDEX `idx_Tg6zFyQpbw` ON `AppUser` (`clerkId`)" + ], "system": false }, { diff --git a/src/app/actions/services/clerk-sync/webhook-handler.ts b/src/app/actions/services/clerk-sync/webhook-handler.ts index e850c0b..7d05fd0 100644 --- a/src/app/actions/services/clerk-sync/webhook-handler.ts +++ b/src/app/actions/services/clerk-sync/webhook-handler.ts @@ -6,7 +6,7 @@ import { linkUserToOrganizationFromClerk, ClerkMembershipData, } from '@/app/actions/services/clerk-sync/syncService' -import { WebhookEvent, clerkClient } from '@clerk/nextjs/server' +import { WebhookEvent, clerkClient, User } from '@clerk/nextjs/server' /** * Result of processing a webhook event @@ -14,6 +14,8 @@ import { WebhookEvent, clerkClient } from '@clerk/nextjs/server' interface WebhookProcessingResult { message: string success: boolean + data?: any + error?: string } /** @@ -26,28 +28,49 @@ export async function processWebhookEvent( ): Promise { console.info(`Processing webhook event: ${event.type}`) + // Validate event data + if (!event || !event.data || !event.type) { + console.error('Invalid webhook event received') + return { + error: 'INVALID_EVENT_FORMAT', + message: 'Invalid webhook event format', + success: false, + } + } + try { // Handle organization events if ( event.type === 'organization.created' || event.type === 'organization.updated' ) { - // Récupérer l'organisation complète depuis Clerk + // Retrieve complete organization data from Clerk const clerkAPI = await clerkClient() const organizationId = event.data.id as string + + if (!organizationId) { + return { + error: 'MISSING_ORGANIZATION_ID', + message: 'Missing organization ID in webhook data', + success: false, + } + } + const completeOrg = await clerkAPI.organizations.getOrganization({ organizationId, }) const organization = await syncOrganizationToPocketBase(completeOrg) return { + data: { organizationId: organization.id }, message: `Organization ${organization.name} (${organization.id}) synchronized successfully`, success: true, } } else if (event.type === 'organization.deleted') { - // Pour l'instant, nous ne supprimons pas réellement les organisations - // C'est un choix de conception pour garder l'historique + // We don't actually delete organizations + // This is a design choice to preserve history return { + data: { organizationId: event.data.id as string }, message: `Organization deletion registered but not processed (data preserved)`, success: true, } @@ -58,31 +81,52 @@ export async function processWebhookEvent( event.type === 'organizationMembership.created' || event.type === 'organizationMembership.updated' ) { - // Convertir les données de membership dans notre format attendu + // Convert membership data to our expected format const membershipData: ClerkMembershipData = { - organization: { id: event.data.organization.id }, + organization: { id: event.data.organization?.id }, public_user_data: { user_id: event.data.public_user_data?.user_id, }, role: event.data.role, } + // Validate required fields + if ( + !membershipData.organization?.id || + !membershipData.public_user_data?.user_id + ) { + return { + error: 'MISSING_MEMBERSHIP_DATA', + message: 'Missing required membership data', + success: false, + } + } + const user = await linkUserToOrganizationFromClerk(membershipData) if (user) { return { + data: { + organizationId: membershipData.organization.id, + userId: user.id, + }, message: `User ${user.name} linked to organization successfully`, success: true, } } else { return { + error: 'LINK_FAILED', message: `Failed to link user to organization`, success: false, } } } else if (event.type === 'organizationMembership.deleted') { - // Pour l'instant, nous ne supprimons pas les liens utilisateur-organisation - // C'est un choix de conception pour conserver l'historique + // We don't actually delete organization memberships + // This is a design choice to preserve history return { + data: { + organizationId: event.data.organization?.id, + userId: event.data.public_user_data?.user_id, + }, message: `Membership deletion registered but not processed (link preserved)`, success: true, } @@ -90,20 +134,114 @@ export async function processWebhookEvent( // Handle user events else if (event.type === 'user.created' || event.type === 'user.updated') { - // Récupérer l'utilisateur complet depuis Clerk - const clerkAPI = await clerkClient() - const userId = event.data.id as string - const completeUser = await clerkAPI.users.getUser(userId) + try { + // Validate user ID + const userId = event.data.id as string + if (!userId) { + return { + error: 'MISSING_USER_ID', + message: 'Missing user ID in webhook data', + success: false, + } + } - const user = await syncUserToPocketBase(completeUser) - return { - message: `User ${user.name} (${user.id}) synchronized successfully`, - success: true, + // Retrieve complete user data from Clerk + const clerkAPI = await clerkClient() + const completeUser = await clerkAPI.users.getUser(userId) + + const user = await syncUserToPocketBase(completeUser) + return { + data: { userId: user.id }, + message: `User ${user.name} (${user.id}) synchronized successfully`, + success: true, + } + } catch (error) { + console.warn( + 'Failed to fetch user from Clerk API, using webhook data:', + error + ) + + // If we can't get the user from the API, try to use the webhook data directly + // This can happen due to replication delay after user creation + if (event.data && typeof event.data === 'object') { + try { + // Validate required email data + const emailAddresses = Array.isArray(event.data.email_addresses) + ? event.data.email_addresses + : [] + + const primaryEmailId = event.data.primary_email_address_id as string + + if (!primaryEmailId || emailAddresses.length === 0) { + return { + error: 'MISSING_EMAIL_DATA', + message: 'Missing required email data in webhook', + success: false, + } + } + + // Construct a minimal user object from webhook data + const webhookUser = { + createdAt: (event.data.created_at || Date.now()) as number, + emailAddresses: emailAddresses.map(email => ({ + emailAddress: email.email_address, + id: email.id, + verification: email.verification, + })), + externalAccounts: [] as Array<{ + provider: string + externalId: string + emailAddress?: string + imageUrl?: string + }>, + firstName: (event.data.first_name || '') as string, + id: event.data.id as string, + imageUrl: (event.data.image_url || '') as string, + lastName: (event.data.last_name || '') as string, + lastSignInAt: (event.data.last_sign_in_at || null) as + | number + | null, + primaryEmailAddressId: primaryEmailId, + publicMetadata: (event.data.public_metadata || {}) as Record< + string, + unknown + >, + updatedAt: (event.data.updated_at || Date.now()) as number, + username: (event.data.username || null) as string | null, + } + + const user = await syncUserToPocketBase( + webhookUser as unknown as User + ) + return { + data: { userId: user.id }, + message: `User ${user.name} (${user.id}) synchronized successfully using webhook data`, + success: true, + } + } catch (webhookError) { + console.error( + 'Error processing user from webhook data:', + webhookError + ) + return { + error: 'WEBHOOK_DATA_PROCESSING_ERROR', + message: `Failed to sync user from webhook data: ${webhookError instanceof Error ? webhookError.message : 'Unknown error'}`, + success: false, + } + } + } + + return { + error: 'USER_SYNC_ERROR', + message: `Failed to sync user: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } } } else if (event.type === 'user.deleted') { - // Pour l'instant, nous ne supprimons pas réellement les utilisateurs - // C'est un choix de conception pour garder l'historique + // We don't actually delete users + // This is a design choice to preserve history return { + data: { userId: event.data.id as string }, message: `User deletion registered but not processed (data preserved)`, success: true, } @@ -112,6 +250,7 @@ export async function processWebhookEvent( // Unknown event type else { return { + error: 'UNKNOWN_EVENT_TYPE', message: `Unhandled webhook event type: ${event.type}`, success: false, } @@ -119,6 +258,7 @@ export async function processWebhookEvent( } catch (error) { console.error(`Error processing webhook ${event.type}:`, error) return { + error: 'WEBHOOK_PROCESSING_ERROR', message: `Error processing webhook: ${error instanceof Error ? error.message : 'Unknown error'}`, success: false, } diff --git a/src/app/actions/services/pocketbase/api_client/client.ts b/src/app/actions/services/pocketbase/api_client/client.ts index 98bc5a5..9edf924 100644 --- a/src/app/actions/services/pocketbase/api_client/client.ts +++ b/src/app/actions/services/pocketbase/api_client/client.ts @@ -85,7 +85,7 @@ export type CollectionName = (typeof Collections)[keyof typeof Collections] */ export function validateWithZod(schema: z.ZodType, data: unknown): T { try { - console.log('data', data) + console.info('data', data) return schema.parse(data) } catch (error) { if (error instanceof z.ZodError) { diff --git a/src/app/actions/services/pocketbase/app_user_service.ts b/src/app/actions/services/pocketbase/app_user_service.ts index 814b939..86ff927 100644 --- a/src/app/actions/services/pocketbase/app_user_service.ts +++ b/src/app/actions/services/pocketbase/app_user_service.ts @@ -1,5 +1,3 @@ -'use server' - import { BaseService } from '@/app/actions/services/pocketbase/api_client' import { AppUser, diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts index 4f91961..546c67b 100644 --- a/src/app/actions/services/pocketbase/equipment_service.ts +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -1,5 +1,3 @@ -'use server' - import { BaseService } from '@/app/actions/services/pocketbase/api_client' import { Collections, diff --git a/src/app/actions/services/pocketbase/organization_service.ts b/src/app/actions/services/pocketbase/organization_service.ts index 713f410..eea12ee 100644 --- a/src/app/actions/services/pocketbase/organization_service.ts +++ b/src/app/actions/services/pocketbase/organization_service.ts @@ -1,5 +1,3 @@ -'use server' - import { BaseService } from '@/app/actions/services/pocketbase/api_client' import { Collections, diff --git a/src/app/api/webhook/clerk/organization-membership/route.ts b/src/app/api/webhook/clerk/organization-membership/route.ts index bb651e8..ec41cb2 100644 --- a/src/app/api/webhook/clerk/organization-membership/route.ts +++ b/src/app/api/webhook/clerk/organization-membership/route.ts @@ -10,9 +10,6 @@ export async function POST(req: NextRequest) { console.info('Received Clerk organization membership webhook request') try { - // Parse the request body - const body = (await req.json()) as WebhookEvent - // Get the Svix headers for verification const svixId = req.headers.get('svix-id') const svixTimestamp = req.headers.get('svix-timestamp') @@ -26,13 +23,13 @@ export async function POST(req: NextRequest) { }) } - // Verify the webhook signature - const isValid = await verifyClerkWebhook( + // Verify the webhook signature and get the parsed body + const verificationResult = await verifyClerkWebhook( req, - process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION_MEMBERSHIP ) - if (!isValid) { + if (!verificationResult.success || !verificationResult.payload) { console.error( 'Invalid webhook signature for organization membership event' ) @@ -41,6 +38,8 @@ export async function POST(req: NextRequest) { }) } + // Use the parsed payload from the verification + const body = verificationResult.payload as WebhookEvent console.info('Webhook verified successfully:', { type: body.type }) // Process the webhook using our central handler diff --git a/src/app/api/webhook/clerk/organization/route.ts b/src/app/api/webhook/clerk/organization/route.ts index 6ace53c..0fc825b 100644 --- a/src/app/api/webhook/clerk/organization/route.ts +++ b/src/app/api/webhook/clerk/organization/route.ts @@ -10,9 +10,6 @@ export async function POST(req: NextRequest) { console.info('Received Clerk organization webhook request') try { - // Parse the request body - const body = (await req.json()) as WebhookEvent - // Get the Svix headers for verification const svixId = req.headers.get('svix-id') const svixTimestamp = req.headers.get('svix-timestamp') @@ -26,19 +23,21 @@ export async function POST(req: NextRequest) { }) } - // Verify the webhook signature - const isValid = await verifyClerkWebhook( + // Verify the webhook signature and get the parsed body + const verificationResult = await verifyClerkWebhook( req, process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION ) - if (!isValid) { + if (!verificationResult.success || !verificationResult.payload) { console.error('Invalid webhook signature for organization event') return new NextResponse('Unauthorized: Invalid signature', { status: 401, }) } + // Use the parsed payload from the verification + const body = verificationResult.payload as WebhookEvent console.info('Webhook verified successfully:', { type: body.type }) // Process the webhook using our central handler diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts index 59d965b..973c42c 100644 --- a/src/app/api/webhook/clerk/user/route.ts +++ b/src/app/api/webhook/clerk/user/route.ts @@ -10,9 +10,6 @@ export async function POST(req: NextRequest) { console.info('Received Clerk user webhook request') try { - // Parse the request body - const body = (await req.json()) as WebhookEvent - // Get the Svix headers for verification const svixId = req.headers.get('svix-id') const svixTimestamp = req.headers.get('svix-timestamp') @@ -26,19 +23,21 @@ export async function POST(req: NextRequest) { }) } - // Verify the webhook signature - const isValid = await verifyClerkWebhook( + // Verify the webhook signature and get the parsed body + const verificationResult = await verifyClerkWebhook( req, process.env.CLERK_WEBHOOK_SECRET_USER ) - if (!isValid) { + if (!verificationResult.success || !verificationResult.payload) { console.error('Invalid webhook signature for user event') return new NextResponse('Unauthorized: Invalid signature', { status: 401, }) } + // Use the parsed payload from the verification + const body = verificationResult.payload as WebhookEvent console.info('Webhook verified successfully:', { type: body.type }) // Process the webhook using our central handler From 969bdef7c0c94bc8353638c1935efdc8ccb32b85 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 19:38:11 +0200 Subject: [PATCH 57/73] feat(pocketbase): add PocketBase API client and schemas - Introduce structured API client for PocketBase with type-safe access - Implement Zod validation schemas for data integrity - Create services for organization, user, equipment, and project management - Add security checks for resource access in service methods - Include example usage in documentation for clarity - Set up environment variables template for configuration --- env.example | 12 + .../services/clerk-sync/cacheService.ts | 0 src/app/actions/services/pocketbase/README.md | 107 ++++++ .../services/pocketbase/api_client/schemas.ts | 154 +++++++++ .../services/pocketbase/api_client/types.ts | 143 ++++++++ .../services/pocketbase/base_service_fix.ts | 13 + .../services/pocketbase/projectService.ts | 306 ++++++++++++++++++ src/components/app/app-sidebar.tsx | 55 ++-- src/components/app/top-bar.tsx | 2 +- 9 files changed, 772 insertions(+), 20 deletions(-) create mode 100644 env.example create mode 100644 src/app/actions/services/clerk-sync/cacheService.ts create mode 100644 src/app/actions/services/pocketbase/README.md create mode 100644 src/app/actions/services/pocketbase/api_client/schemas.ts create mode 100644 src/app/actions/services/pocketbase/api_client/types.ts create mode 100644 src/app/actions/services/pocketbase/base_service_fix.ts create mode 100644 src/app/actions/services/pocketbase/projectService.ts diff --git a/env.example b/env.example new file mode 100644 index 0000000..daed409 --- /dev/null +++ b/env.example @@ -0,0 +1,12 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=-- +CLERK_SECRET_KEY=-- +NEXT_PUBLIC_CLERK_SIGN_IN_URL=--/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=--/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=--/app + +PB_TOKEN_API_ADMIN=--- +PB_API_URL=--- + +CLERK_WEBHOOK_SECRET_ORGANIZATION=-- +CLERK_WEBHOOK_SECRET_USER=-- +CLERK_WEBHOOK_SECRET_ORGANIZATION_MEMBERSHIP=-- \ No newline at end of file diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/actions/services/pocketbase/README.md b/src/app/actions/services/pocketbase/README.md new file mode 100644 index 0000000..700d6aa --- /dev/null +++ b/src/app/actions/services/pocketbase/README.md @@ -0,0 +1,107 @@ +# PocketBase API Client Architecture + +This directory contains a structured API client for PocketBase, designed to provide type-safe, validated data access with clean abstractions. + +## Directory Structure + +``` +pocketbase/ +├── api_client/ # Core API client components +│ ├── types.ts # TypeScript interfaces for all models +│ ├── schemas.ts # Zod validation schemas +│ ├── client.ts # PocketBase client and utilities +│ ├── base_service.ts # Generic CRUD operations +│ └── index.ts # Re-exports and utilities +├── organization_service.ts # Organization-specific operations +├── app_user_service.ts # User-specific operations +├── equipment_service.ts # Equipment-specific operations +└── index.ts # Re-exports all services +``` + +## Key Features + +1. **Type Safety**: Full TypeScript interfaces for all PocketBase models. +2. **Validation**: Zod schemas for request and response validation. +3. **Error Handling**: Consistent error handling with detailed error messages. +4. **Modularity**: Each collection has its own service with specific methods. +5. **DRY Code**: Common operations are abstracted into the BaseService. +6. **Singleton Pattern**: Services are implemented as singletons for efficient reuse. + +## Usage Examples + +### Finding a User by Clerk ID + +```typescript +import { findUserByClerkId } from '@/app/actions/services/pocketbase' + +const user = await findUserByClerkId('clerk_123') +if (user) { + // User exists in PocketBase + console.log(user.name) +} +``` + +### Creating or Updating an Organization + +```typescript +import { createOrUpdateOrganizationByClerkId } from '@/app/actions/services/pocketbase' + +const org = await createOrUpdateOrganizationByClerkId('clerk_org_123', { + name: 'My Organization', + email: 'org@example.com', + phone: '123-456-7890', + // ... other fields +}) +``` + +### Searching for Equipment + +```typescript +import { searchEquipment } from '@/app/actions/services/pocketbase' + +const items = await searchEquipment(organizationId, 'drill') +console.log(`Found ${items.length} items matching 'drill'`) +``` + +## Implementation Notes + +### BaseService + +The `BaseService` provides generic CRUD operations for any collection: + +- `getById`: Fetch a single record by ID +- `getList`: Get a paginated list of records +- `create`: Create a new record +- `update`: Update an existing record +- `delete`: Delete a record +- `getCount`: Count records matching a filter + +### Service-specific Methods + +Each collection-specific service adds custom methods relevant to that entity: + +- Organization: `findByClerkId`, `createOrUpdateByClerkId` +- AppUser: `findByClerkId`, `linkToOrganization`, `getByOrganization` +- Equipment: `findByQrNfcCode`, `findByOrganization`, `search` + +### Validation + +All data is validated using Zod schemas: + +- Input validation before sending to PocketBase +- Output validation after receiving from PocketBase + +### Error Handling + +Errors are handled consistently across the entire API client: + +- PocketBase errors are converted to a standard format +- Validation errors include detailed information about what failed +- Common error handling with the `handlePocketBaseError` utility + +## Best Practices + +1. Always use exported functions rather than direct service instances +2. Use the specific service methods rather than generic CRUD when possible +3. Handle errors appropriately in your calling code +4. Use the validation utilities to ensure data integrity diff --git a/src/app/actions/services/pocketbase/api_client/schemas.ts b/src/app/actions/services/pocketbase/api_client/schemas.ts new file mode 100644 index 0000000..bed285f --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/schemas.ts @@ -0,0 +1,154 @@ +/** + * Zod schemas for PocketBase data models + * These schemas are used for validation of data going in and out of PocketBase + */ +import { z } from 'zod' + +/** + * Base schema for all PocketBase records + */ +export const baseRecordSchema = z.object({ + collectionId: z.string().optional(), + collectionName: z.string().optional(), + created: z.string().datetime(), + id: z.string(), + updated: z.string().datetime(), +}) + +/** + * Organization schema + */ +export const organizationSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + clerkId: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + name: z.string(), + phone: z.string().optional().or(z.literal('')), + priceId: z.string().optional().or(z.literal('')), + settings: z.record(z.string(), z.unknown()).optional().default({}), + stripeCustomerId: z.string().optional().or(z.literal('')), + subscriptionId: z.string().optional().or(z.literal('')), + subscriptionStatus: z.string().optional().or(z.literal('')), +}) + +/** + * AppUser schema + */ +export const appUserSchema = baseRecordSchema.extend({ + clerkId: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + emailVisibility: z.boolean().optional().default(true), + isAdmin: z.boolean().optional().default(false), + lastLogin: z.string().datetime().optional().or(z.literal('')), + metadata: z + .object({ + createdAt: z.number().optional(), + externalAccounts: z + .array( + z.object({ + email: z.string().email(), + imageUrl: z.string().url(), + provider: z.string(), + providerUserId: z.string(), + }) + ) + .optional(), + hasCompletedOnboarding: z.boolean().optional(), + lastActiveAt: z.number().optional(), + onboardingCompletedAt: z.string().optional(), + public: z + .object({ + hasCompletedOnboarding: z.boolean(), + onboardingCompletedAt: z.string(), + }) + .optional(), + updatedAt: z.number().optional(), + }) + .optional() + .default({}), + name: z.string().optional().or(z.literal('')), + organizations: z.string().optional().or(z.literal('')), + role: z.string().optional().or(z.literal('')), + verified: z.boolean().optional().default(false), +}) + +/** + * Equipment schema + */ +export const equipmentSchema = baseRecordSchema.extend({ + acquisitionDate: z.string().datetime().optional().or(z.literal('')), + name: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + parentEquipment: z.string().optional().or(z.literal('')), + qrNfcCode: z.string(), + tags: z.string().optional().or(z.literal('')), +}) + +/** + * Project schema + */ +export const projectSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + endDate: z.string().datetime().optional().or(z.literal('')), + name: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + startDate: z.string().datetime().optional().or(z.literal('')), +}) + +/** + * Assignment schema + */ +export const assignmentSchema = baseRecordSchema.extend({ + assignedToProject: z.string().optional().or(z.literal('')), + assignedToUser: z.string().optional().or(z.literal('')), + endDate: z.string().datetime().optional().or(z.literal('')), + equipment: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + startDate: z.string().datetime(), +}) + +/** + * ActivityLog schema + */ +export const activityLogSchema = baseRecordSchema.extend({ + equipment: z.string().optional().or(z.literal('')), + metadata: z.record(z.string(), z.unknown()).optional().default({}), + organization: z.string().optional().or(z.literal('')), + user: z.string().optional().or(z.literal('')), +}) + +/** + * Image schema + */ +export const imageSchema = baseRecordSchema.extend({ + alt: z.string().optional().or(z.literal('')), + caption: z.string().optional().or(z.literal('')), + image: z.string(), + title: z.string().optional().or(z.literal('')), +}) + +/** + * List result schema (generic) + */ +export const listResultSchema = (itemSchema: T) => + z.object({ + items: z.array(itemSchema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + +/** + * Query parameters schema + */ +export const queryParamsSchema = z.object({ + expand: z.string().optional(), + filter: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), + sort: z.string().optional(), +}) diff --git a/src/app/actions/services/pocketbase/api_client/types.ts b/src/app/actions/services/pocketbase/api_client/types.ts new file mode 100644 index 0000000..a53d27a --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/types.ts @@ -0,0 +1,143 @@ +/** + * Core types for PocketBase data models + * These types represent the schema of our database collections + */ + +/** + * Base type for all PocketBase records + */ +export interface BaseRecord { + id: string + created: string + updated: string + collectionId?: string + collectionName?: string +} + +/** + * Organization record + */ +export interface Organization extends BaseRecord { + name: string + email: string | '' + phone: string | '' + address: string | '' + settings: Record + clerkId: string + stripeCustomerId: string | '' + subscriptionId: string | '' + subscriptionStatus: string | '' + priceId: string | '' +} + +/** + * AppUser record + */ +export interface AppUser extends BaseRecord { + email: string | '' + emailVisibility: boolean + verified: boolean + name: string | '' + role: string | '' + isAdmin: boolean + lastLogin: string | '' + clerkId: string + organizations: string | '' + metadata: { + createdAt: number + externalAccounts?: Array<{ + email: string + imageUrl: string + provider: string + providerUserId: string + }> + hasCompletedOnboarding?: boolean + lastActiveAt?: number + onboardingCompletedAt?: string + public?: { + hasCompletedOnboarding: boolean + onboardingCompletedAt: string + } + updatedAt?: number + } +} + +/** + * Equipment record + */ +export interface Equipment extends BaseRecord { + organization: string + name: string + qrNfcCode: string + tags: string | '' + notes: string | '' + acquisitionDate: string | '' + parentEquipment?: string | '' +} + +/** + * Project record + */ +export interface Project extends BaseRecord { + name: string + address: string | '' + notes: string | '' + startDate: string | '' + endDate: string | '' + organization: string +} + +/** + * Assignment record + */ +export interface Assignment extends BaseRecord { + organization: string + equipment: string + assignedToUser?: string | '' + assignedToProject?: string | '' + startDate: string + endDate: string | '' + notes: string | '' +} + +/** + * ActivityLog record + */ +export interface ActivityLog extends BaseRecord { + organization?: string | '' + user?: string | '' + equipment?: string | '' + metadata: Record +} + +/** + * Image record + */ +export interface Image extends BaseRecord { + title: string | '' + alt: string | '' + caption: string | '' + image: string +} + +/** + * PocketBase response types + */ +export interface ListResult { + page: number + perPage: number + totalItems: number + totalPages: number + items: T[] +} + +/** + * Generic query parameters for list operations + */ +export interface QueryParams { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} diff --git a/src/app/actions/services/pocketbase/base_service_fix.ts b/src/app/actions/services/pocketbase/base_service_fix.ts new file mode 100644 index 0000000..8b1f8cd --- /dev/null +++ b/src/app/actions/services/pocketbase/base_service_fix.ts @@ -0,0 +1,13 @@ +/** + * Utility function to fix type compatibility issues + * This function is a workaround for TypeScript type compatibility issues + * between Zod schemas and TypeScript interfaces + * + * @param schema The Zod schema to be fixed + * @returns The same schema with fixed type compatibility + */ +export function fixSchemaType(schema: any): any { + // Simply pass through the schema but with a different type signature + // This is a type assertion hack to make TypeScript happy + return schema +} diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts new file mode 100644 index 0000000..da7153a --- /dev/null +++ b/src/app/actions/services/pocketbase/projectService.ts @@ -0,0 +1,306 @@ +'use server' + +import { + getPocketBase, + handlePocketBaseError, +} from '@/app/actions/services/pocketbase/baseService' +import { + validateOrganizationAccess, + validateResourceAccess, + createOrganizationFilter, +} from '@/app/actions/services/pocketbase/securityUtils' +import { + PermissionLevel, + ResourceType, + SecurityError, +} from '@/app/actions/services/securyUtilsTools' +import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' + +/** + * Get a single project by ID with security validation + */ +export async function getProject(id: string): Promise { + try { + // Security check - validates user has access to this resource + await validateResourceAccess(ResourceType.PROJECT, id, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + return await pb.collection('projects').getOne(id) + } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } + return handlePocketBaseError(error, 'ProjectService.getProject') + } +} + +/** + * Get projects list with pagination and security checks + */ +export async function getProjectsList( + organizationId: string, + options: ListOptions = {} +): Promise> { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = await createOrganizationFilter( + organizationId, + additionalFilter + ) + + return await pb.collection('projects').getList(page, perPage, { + ...rest, + filter, + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.getProjectsList') + } +} + +/** + * Get all projects for an organization with security checks + */ +export async function getOrganizationProjects( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Apply organization filter - fixed field name + const filter = `organizationId="${organizationId}"` + + return await pb.collection('projects').getFullList({ + filter, + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError( + error, + 'ProjectService.getOrganizationProjects' + ) + } +} + +/** + * Get active projects with security checks + * (current date is between startDate and endDate or endDate is not set) + */ +export async function getActiveProjects( + organizationId: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + const now = new Date().toISOString() + + // Fixed field name in filter + return await pb.collection('projects').getFullList({ + filter: pb.filter( + 'organizationId = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', + { now, orgId: organizationId } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.getActiveProjects') + } +} + +/** + * Create a new project with security checks + */ +export async function createProject( + organizationId: string, + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > +): Promise { + try { + // Security check - requires WRITE permission + await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Ensure organization ID is set correctly - fixed field name + return await pb.collection('projects').create({ + ...data, + organizationId, // Force the correct organization ID + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.createProject') + } +} + +/** + * Update a project with security checks + */ +export async function updateProject( + id: string, + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > +): Promise { + try { + // Security check - requires WRITE permission + await validateResourceAccess( + ResourceType.PROJECT, + id, + PermissionLevel.WRITE + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Never allow changing the organization + const sanitizedData = { ...data } + // Fixed 'any' type and field name + delete (sanitizedData as Record).organizationId + + return await pb.collection('projects').update(id, sanitizedData) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.updateProject') + } +} + +/** + * Delete a project with security checks + */ +export async function deleteProject(id: string): Promise { + try { + // Security check - requires ADMIN permission for deletion + await validateResourceAccess( + ResourceType.PROJECT, + id, + PermissionLevel.ADMIN + ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + await pb.collection('projects').delete(id) + return true + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.deleteProject') + } +} + +/** + * Get project count for an organization with security checks + */ +export async function getProjectCount(organizationId: string): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed field name + const result = await pb.collection('projects').getList(1, 1, { + filter: `organizationId=${organizationId}`, + skipTotal: false, + }) + + return result.totalItems + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.getProjectCount') + } +} + +/** + * Search projects by name or address with security checks + */ +export async function searchProjects( + organizationId: string, + query: string +): Promise { + try { + // Security check + await validateOrganizationAccess(organizationId, PermissionLevel.READ) + + const pb = await getPocketBase() + if (!pb) { + throw new Error('Failed to connect to PocketBase') + } + + // Fixed field name in filter + return await pb.collection('projects').getFullList({ + filter: pb.filter( + 'organizationId = {:orgId} && (name ~ {:query} || address ~ {:query})', + { + orgId: organizationId, + query, + } + ), + sort: 'name', + }) + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + return handlePocketBaseError(error, 'ProjectService.searchProjects') + } +} diff --git a/src/components/app/app-sidebar.tsx b/src/components/app/app-sidebar.tsx index 56b9a1e..faf8498 100644 --- a/src/components/app/app-sidebar.tsx +++ b/src/components/app/app-sidebar.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { + OrganizationSwitcher, RedirectToSignIn, SignedIn, SignedOut, @@ -30,6 +31,18 @@ import { import Link from 'next/link' import { usePathname } from 'next/navigation' +const DotIcon = () => { + return ( + + + + ) +} + export function AppSidebar() { const pathname = usePathname() @@ -157,25 +170,6 @@ export function AppSidebar() { - - - - - - - - - - Organisation - - @@ -198,6 +192,29 @@ export function AppSidebar() { Profil + + + + + +
+ +
+
+
+
+
+
diff --git a/src/components/app/top-bar.tsx b/src/components/app/top-bar.tsx index cfe33d7..9632feb 100644 --- a/src/components/app/top-bar.tsx +++ b/src/components/app/top-bar.tsx @@ -1,5 +1,5 @@ 'use client' -import { SignedIn } from '@clerk/nextjs' +import { OrganizationSwitcher, SignedIn } from '@clerk/nextjs' import { Search } from 'lucide-react' import { usePathname } from 'next/navigation' From 4eab938158b8a45c61e694b298a32a1c2caa01fb Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Mon, 31 Mar 2025 20:24:24 +0200 Subject: [PATCH 58/73] feat(db): update schema for Organization and AppUser - Remove unnecessary fields from Organization schema - Add unique index for clerkId in Organization - Introduce new OrganizationAppUser schema with relations to organization and appUser - Implement autodate fields for created and updated timestamps --- .cursor/md/example-export-pb-schema.md | 130 ++++++++++++++++++++----- 1 file changed, 104 insertions(+), 26 deletions(-) diff --git a/.cursor/md/example-export-pb-schema.md b/.cursor/md/example-export-pb-schema.md index 4393435..8f2ab85 100644 --- a/.cursor/md/example-export-pb-schema.md +++ b/.cursor/md/example-export-pb-schema.md @@ -169,20 +169,6 @@ "system": false, "type": "text" }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1466534506", - "max": 0, - "min": 0, - "name": "role", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, { "hidden": false, "id": "date2697416787", @@ -209,27 +195,27 @@ "type": "text" }, { - "cascadeDelete": false, - "collectionId": "pbc_2387082370", "hidden": false, - "id": "relation1115430015", - "maxSelect": 1, - "minSelect": 0, - "name": "organizations", + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", "presentable": false, "required": false, "system": false, - "type": "relation" + "type": "json" }, { + "cascadeDelete": false, + "collectionId": "pbc_461999422", "hidden": false, - "id": "json1326724116", - "maxSize": 0, - "name": "metadata", + "id": "relation1115430015", + "maxSelect": 999, + "minSelect": 0, + "name": "organizations", "presentable": false, "required": false, "system": false, - "type": "json" + "type": "relation" }, { "hidden": false, @@ -813,7 +799,99 @@ "type": "autodate" } ], - "indexes": [], + "indexes": [ + "CREATE UNIQUE INDEX `idx_MHRKs66UDJ` ON `Organization` (`clerkId`)" + ], + "system": false + }, + { + "id": "pbc_461999422", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "OrganizationAppUser", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation3253625724", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_879879449", + "hidden": false, + "id": "relation1320735562", + "maxSelect": 1, + "minSelect": 0, + "name": "appUser", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1466534506", + "max": 0, + "min": 0, + "name": "role", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_QDEdzobtpm` ON `OrganizationAppUser` (\n `organization`,\n `appUser`\n)" + ], "system": false }, { From 2124c84366a4304b05876da355c484a58004d40b Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Fri, 4 Apr 2025 17:48:02 +0200 Subject: [PATCH 59/73] feat(core): enhance user and organization sync functionality - Implement functions to ensure synchronization of users and organizations between Clerk and PocketBase - Add error handling for unauthorized access during sync operations - Create a new service for managing organization-user mappings - Update existing services to utilize the new mapping service - Refactor middleware to include logging and improved sync checks - Remove outdated security utility functions, consolidating validation logic into a new module --- .cursor/rules/rules-stack-technique.mdc | 1 - .../services/clerk-sync/syncMiddleware.ts | 280 ++++++++++----- .../services/clerk-sync/syncService.ts | 15 +- .../services/clerk-sync/webhook-handler.ts | 69 +++- .../services/pocketbase/app_user_service.ts | 56 ++- .../organization_app_user_service.ts | 327 ++++++++++++++++++ .../services/pocketbase/securityUtils.ts | 139 -------- .../services/pocketbase/security_utils.ts | 126 +++++++ src/middleware.ts | 4 +- src/models/pocketbase/app-user.model.ts | 7 +- src/models/pocketbase/collections.model.ts | 1 + src/models/pocketbase/collections.ts | 13 + src/models/pocketbase/index.ts | 6 + .../pocketbase/organization-app-user.model.ts | 76 ++++ 14 files changed, 863 insertions(+), 257 deletions(-) create mode 100644 src/app/actions/services/pocketbase/organization_app_user_service.ts delete mode 100644 src/app/actions/services/pocketbase/securityUtils.ts create mode 100644 src/app/actions/services/pocketbase/security_utils.ts create mode 100644 src/models/pocketbase/collections.ts create mode 100644 src/models/pocketbase/organization-app-user.model.ts diff --git a/.cursor/rules/rules-stack-technique.mdc b/.cursor/rules/rules-stack-technique.mdc index 455a0e9..067f03b 100644 --- a/.cursor/rules/rules-stack-technique.mdc +++ b/.cursor/rules/rules-stack-technique.mdc @@ -131,7 +131,6 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les ## 9. Documentation - **Swagger/OpenAPI** - Documentation d'API auto-générée -- **Docusaurus** - Documentation utilisateur et technique ## 10. Structure du projet ``` diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts index 91ba76d..d43810e 100644 --- a/src/app/actions/services/clerk-sync/syncMiddleware.ts +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -1,124 +1,240 @@ 'use server' -import { auth, clerkClient } from '@clerk/nextjs/server' -import { NextResponse } from 'next/server' +import { AppUser, Organization } from '@/models/pocketbase' +import { clerkClient, OrganizationMembership } from '@clerk/nextjs/server' import { - syncUserToPocketBase, + findUserByClerkId, + getAppUserService, +} from '../pocketbase/app_user_service' +import { + createOrUpdateOrganizationUserMapping, + getOrganizationAppUserService, +} from '../pocketbase/organization_app_user_service' +import { findOrganizationByClerkId } from '../pocketbase/organization_service' +import { syncOrganizationToPocketBase, - linkUserToOrganizationFromClerk, - ClerkMembershipData, + syncUserToPocketBase, } from './syncService' /** - * Type for any data accepted by server actions + * Ensures a user is synchronized between Clerk and PocketBase + * @param clerkUserId - The Clerk user ID + * @returns The PocketBase user */ -type ActionData = Record +export async function ensureUserSync(clerkUserId: string): Promise { + // Check if user already exists in PocketBase + const existingUser = await findUserByClerkId(clerkUserId) + if (existingUser) { + return existingUser + } + + // User not found, sync from Clerk + console.info(`User ${clerkUserId} not found in PocketBase, syncing...`) + const clerk = await clerkClient() + const clerkUser = await clerk.users.getUser(clerkUserId) + + return syncUserToPocketBase(clerkUser) +} /** - * Middleware function to ensure user and organization data is synced - * Acts as a fallback in case webhooks fail - * - * @returns The modified response + * Ensures an organization is synchronized between Clerk and PocketBase + * @param clerkOrgId - The Clerk organization ID + * @returns The PocketBase organization */ -export async function syncMiddleware() { - // Only run this middleware for authenticated routes - const { orgId, userId } = await auth() - - if (!userId) { - // User is not authenticated, skip this middleware - return NextResponse.next() +export async function ensureOrgSync(clerkOrgId: string): Promise { + // Check if organization already exists in PocketBase + const existingOrg = await findOrganizationByClerkId(clerkOrgId) + if (existingOrg) { + return existingOrg } - try { - // Perform the sync without using cache - await ensureUserAndOrgSync(userId, orgId) - } catch (error) { - // Log error but don't block the request - // This ensures the app remains functional even if sync fails - console.error('Sync middleware error:', error) - } + // Organization not found, sync from Clerk + console.info(`Organization ${clerkOrgId} not found in PocketBase, syncing...`) + const clerk = await clerkClient() + const clerkOrg = await clerk.organizations.getOrganization({ + organizationId: clerkOrgId, + }) - // Continue with the request - return NextResponse.next() + return syncOrganizationToPocketBase(clerkOrg) } /** - * Ensures user and organization data is synchronized between Clerk and PocketBase - * - * @param clerkUserId The Clerk user ID - * @param clerkOrgId The Clerk organization ID (optional) - * @returns Object containing the synchronized user and organization + * Ensures a user and organization are synchronized and linked + * @param clerkUserId - The Clerk user ID + * @param clerkOrgId - The Clerk organization ID + * @returns The PocketBase user and organization */ export async function ensureUserAndOrgSync( clerkUserId: string, - clerkOrgId?: string | null -) { - // 1. First, try to get fresh data from Clerk - const clerkAPI = await clerkClient() - const clerkUser = await clerkAPI.users.getUser(clerkUserId) - - // 2. Sync the user to PocketBase - await syncUserToPocketBase(clerkUser) - - // 3. If an organization ID is provided, sync that too - if (clerkOrgId) { - const clerkOrg = await clerkAPI.organizations.getOrganization({ - organizationId: clerkOrgId, - }) - - await syncOrganizationToPocketBase(clerkOrg) - - // 4. Ensure the user-organization relationship exists - const memberships = - await clerkAPI.organizations.getOrganizationMembershipList({ + clerkOrgId: string +): Promise<{ user: AppUser; org: Organization }> { + try { + // First ensure both user and org exist in PocketBase + const [user, org] = await Promise.all([ + ensureUserSync(clerkUserId), + ensureOrgSync(clerkOrgId), + ]) + + if (!user || !org) { + console.error( + `Failed to sync user ${clerkUserId} or organization ${clerkOrgId}` + ) + throw new Error('User or organization sync failed') + } + + // Get the user's role in the organization from Clerk + const clerk = await clerkClient() + + // Get all memberships for the organization + const memberships = await clerk.organizations.getOrganizationMembershipList( + { organizationId: clerkOrgId, - }) + } + ) - // Trouver le membership pour cet utilisateur + // Validate that the user is actually a member of this organization const membership = memberships.data.find( m => m.publicUserData?.userId === clerkUserId ) - if (membership) { - // Prepare membership data in the format expected by linkUserToOrganization - const membershipData: ClerkMembershipData = { - organization: { id: clerkOrgId }, - public_user_data: { user_id: clerkUserId }, - role: membership.role, + if (!membership) { + console.warn( + `User ${clerkUserId} is not a member of organization ${clerkOrgId} in Clerk` + ) + // Get organization app user service + const orgAppUserService = getOrganizationAppUserService() + + // Check if there's an incorrect mapping in PocketBase + const existingMapping = + await orgAppUserService.findByAppUserAndOrganization(user.id, org.id) + + // If an incorrect mapping exists, remove it for security + if (existingMapping) { + console.warn( + `Removing unauthorized mapping for user ${user.id} in org ${org.id}` + ) + await orgAppUserService.deleteMapping(user.id, org.id) } - await linkUserToOrganizationFromClerk(membershipData) + throw new Error( + `User ${clerkUserId} is not authorized to access organization ${clerkOrgId}` + ) } - } - // Return the operation result - return { - status: 'success', - syncedAt: new Date().toISOString(), + // Default to 'member' if no specific role is found + const role = membership.role?.replace('org:', '') || 'member' + + // Create or update the mapping in the junction table + await createOrUpdateOrganizationUserMapping(user.id, org.id, role) + + // Verify all other memberships to ensure consistency between Clerk and PocketBase + await verifyAllOrganizationMemberships(clerkOrgId, org.id, memberships.data) + + return { org, user } + } catch (error) { + console.error( + `Error ensuring user-org sync: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + throw error } } -// For server action use, we create a higher-order function /** - * Higher-order function that wraps server actions to ensure sync before execution - * - * @param handler The server action handler function - * @returns The wrapped handler with sync check + * Verifies that all memberships match between Clerk and PocketBase + * @param clerkOrgId - The Clerk organization ID + * @param pbOrgId - The PocketBase organization ID + * @param clerkMemberships - The list of memberships from Clerk */ -export function withSync(handler: (data: ActionData) => Promise) { - return async function syncProtectedAction(data: ActionData): Promise { - 'use server' +async function verifyAllOrganizationMemberships( + clerkOrgId: string, + pbOrgId: string, + clerkMemberships: OrganizationMembership[] +): Promise { + try { + // Get the organization-app-user service + const orgAppUserService = getOrganizationAppUserService() - // Get auth context - const { orgId, userId } = await auth() + // Get all mappings for this organization in PocketBase + const pbMappings = await orgAppUserService.findByOrganizationId(pbOrgId) - if (userId) { - // Ensure data is synced before proceeding - await ensureUserAndOrgSync(userId, orgId) + // Get the app user service to look up users by clerk ID + const appUserService = getAppUserService() + + // For each PocketBase mapping, verify it exists in Clerk + for (const pbMapping of pbMappings) { + // Get the app user from PocketBase + const appUser = await appUserService.getById(pbMapping.appUser) + + if (!appUser || !appUser.clerkId) { + console.warn( + `User ${pbMapping.appUser} has no clerkId, skipping verification` + ) + continue + } + + // Check if this user is a member in Clerk + const clerkMembership = clerkMemberships.find( + m => m.publicUserData?.userId === appUser.clerkId + ) + + // If no membership exists in Clerk but exists in PocketBase, remove it + if (!clerkMembership) { + console.warn( + `User ${appUser.clerkId} is not a member of org ${clerkOrgId} in Clerk, removing from PocketBase` + ) + await orgAppUserService.deleteMapping(pbMapping.appUser, pbOrgId) + continue + } + + // If the roles don't match, update the PocketBase role + const clerkRole = clerkMembership.role?.replace('org:', '') || 'member' + if (pbMapping.role !== clerkRole) { + console.info( + `Updating role for user ${appUser.clerkId} in org ${clerkOrgId} from ${pbMapping.role} to ${clerkRole}` + ) + await orgAppUserService.createOrUpdate( + pbMapping.appUser, + pbOrgId, + clerkRole + ) + } } - // Execute the original handler - return handler(data) + // For each Clerk membership, ensure it exists in PocketBase + for (const clerkMembership of clerkMemberships) { + const clerkUserId = clerkMembership.publicUserData?.userId + if (!clerkUserId) { + console.warn('Clerk membership has no userId, skipping') + continue + } + + // Find the user in PocketBase + const pbUser = await appUserService.findByClerkId(clerkUserId) + if (!pbUser) { + console.info( + `User ${clerkUserId} not found in PocketBase, will be synced on next access` + ) + continue + } + + // Check if mapping exists + const pbMapping = await orgAppUserService.findByAppUserAndOrganization( + pbUser.id, + pbOrgId + ) + + // If no mapping exists in PocketBase but exists in Clerk, create it + if (!pbMapping) { + const role = clerkMembership.role?.replace('org:', '') || 'member' + console.info( + `Creating missing mapping for user ${clerkUserId} in org ${clerkOrgId}` + ) + await orgAppUserService.createOrUpdate(pbUser.id, pbOrgId, role) + } + } + } catch (error) { + console.error('Error verifying organization memberships:', error) + // Don't throw, just log the error to prevent breaking the main sync flow } } diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts index f3e3226..5e6dd51 100644 --- a/src/app/actions/services/clerk-sync/syncService.ts +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -12,6 +12,7 @@ import { AppUserCreateInput, createOrUpdateUserByClerkId, } from '../pocketbase/app_user_service' +import { createOrUpdateOrganizationUserMapping } from '../pocketbase/organization_app_user_service' import { getOrganizationService, OrganizationCreateInput, @@ -97,11 +98,6 @@ export async function syncUserToPocketBase(clerkUser: User): Promise { clerkUser.username || 'Unknown', organizations: '', - role: - typeof clerkUser.publicMetadata?.role === 'string' - ? (clerkUser.publicMetadata.role as string) - : 'member', - verified: primaryEmail.verification?.status === 'verified', } // Use the utility function to create/update the user @@ -168,7 +164,7 @@ export async function syncOrganizationToPocketBase( /** * Links a user to an organization based on Clerk membership data * @param membershipData - The membership data from Clerk - * @returns Success status + * @returns The updated user or null if linking failed */ export async function linkUserToOrganizationFromClerk( membershipData: ClerkMembershipData @@ -203,8 +199,11 @@ export async function linkUserToOrganizationFromClerk( `Found user ${user.id} and organization ${org.id} in PocketBase` ) - // Link the user to the organization - return await appUserService.linkToOrganization(user.id, org.id, role) + // Create or update the relation in the junction table + await createOrUpdateOrganizationUserMapping(user.id, org.id, role) + + // Return the user record + return user } catch (error) { console.error('Error linking user to organization:', error) return null diff --git a/src/app/actions/services/clerk-sync/webhook-handler.ts b/src/app/actions/services/clerk-sync/webhook-handler.ts index 7d05fd0..a0b0cdf 100644 --- a/src/app/actions/services/clerk-sync/webhook-handler.ts +++ b/src/app/actions/services/clerk-sync/webhook-handler.ts @@ -6,6 +6,7 @@ import { linkUserToOrganizationFromClerk, ClerkMembershipData, } from '@/app/actions/services/clerk-sync/syncService' +import { createOrUpdateOrganizationUserMapping } from '@/app/actions/services/pocketbase/organization_app_user_service' import { WebhookEvent, clerkClient, User } from '@clerk/nextjs/server' /** @@ -14,7 +15,7 @@ import { WebhookEvent, clerkClient, User } from '@clerk/nextjs/server' interface WebhookProcessingResult { message: string success: boolean - data?: any + data?: Record error?: string } @@ -102,20 +103,64 @@ export async function processWebhookEvent( } } - const user = await linkUserToOrganizationFromClerk(membershipData) - if (user) { - return { - data: { - organizationId: membershipData.organization.id, - userId: user.id, - }, - message: `User ${user.name} linked to organization successfully`, - success: true, + try { + const user = await linkUserToOrganizationFromClerk(membershipData) + if (user) { + return { + data: { + organizationId: membershipData.organization.id, + userId: user.id, + }, + message: `User ${user.name} linked to organization successfully`, + success: true, + } + } else { + // Try to perform initial sync of user and organization if linking failed + const userId = membershipData.public_user_data.user_id + const orgId = membershipData.organization.id + + console.info( + `Attempting to sync user ${userId} and org ${orgId} before linking` + ) + const clerk = await clerkClient() + + try { + // Get complete data from Clerk + const [clerkUser, clerkOrg] = await Promise.all([ + clerk.users.getUser(userId), + clerk.organizations.getOrganization({ organizationId: orgId }), + ]) + + // Sync both to PocketBase + const pbUser = await syncUserToPocketBase(clerkUser) + const pbOrg = await syncOrganizationToPocketBase(clerkOrg) + + // Try linking again + await createOrUpdateOrganizationUserMapping( + pbUser.id, + pbOrg.id, + membershipData.role?.replace('org:', '') || 'member' + ) + + return { + data: { organizationId: pbOrg.id, userId: pbUser.id }, + message: `User ${pbUser.name} linked to organization ${pbOrg.name} after sync`, + success: true, + } + } catch (syncError) { + console.error('Error syncing before linking:', syncError) + return { + error: 'SYNC_BEFORE_LINK_FAILED', + message: `Failed to link after sync attempt: ${syncError instanceof Error ? syncError.message : 'Unknown error'}`, + success: false, + } + } } - } else { + } catch (error) { + console.error('Error linking user to organization:', error) return { error: 'LINK_FAILED', - message: `Failed to link user to organization`, + message: `Failed to link user to organization: ${error instanceof Error ? error.message : 'Unknown error'}`, success: false, } } diff --git a/src/app/actions/services/pocketbase/app_user_service.ts b/src/app/actions/services/pocketbase/app_user_service.ts index 86ff927..3966dd0 100644 --- a/src/app/actions/services/pocketbase/app_user_service.ts +++ b/src/app/actions/services/pocketbase/app_user_service.ts @@ -1,4 +1,5 @@ import { BaseService } from '@/app/actions/services/pocketbase/api_client' +import { getPocketBase } from '@/app/actions/services/pocketbase/api_client/client' import { AppUser, AppUserCreateInput, @@ -38,11 +39,25 @@ export class AppUserService extends BaseService< */ async findByClerkId(clerkId: string): Promise { try { - const result = await this.getList({ - filter: `clerkId = "${clerkId}"`, + // Use getPocketBase directly to avoid validation issues + const pb = getPocketBase() + const records = await pb.collection(this.collectionName).getFullList({ + filter: `clerkId="${clerkId}"`, }) - return result.items.length > 0 ? result.items[0] : null + if (records.length === 0) { + return null + } + + // Clean and normalize the response data + const record = records[0] + + // Fix organizations field if it's an array + if (Array.isArray(record.organizations)) { + record.organizations = '' + } + + return record as unknown as AppUser } catch (error) { console.error('Error finding user by clerkId:', error) return null @@ -76,19 +91,34 @@ export class AppUserService extends BaseService< clerkId: string, data: Omit ): Promise { - const existing = await this.findByClerkId(clerkId) - - if (existing) { - return this.update(existing.id, { + try { + const existing = await this.findByClerkId(clerkId) + + if (existing) { + console.info(`Updating existing user with clerkId: ${clerkId}`) + // When updating, ensure we don't overwrite organizations field with an empty string + // if the user already has organizations + const updateData = { + ...data, + clerkId, + } as AppUserUpdateInput + + return this.update(existing.id, updateData, { validateOutput: false }) + } + + console.info(`Creating new user with clerkId: ${clerkId}`) + // For new users, make sure organizations is a string + const createData = { ...data, clerkId, - }) - } + organizations: data.organizations || '', + } as AppUserCreateInput - return this.create({ - ...data, - clerkId, - }) + return this.create(createData, { validateOutput: false }) + } catch (error) { + console.error('Error in createOrUpdateByClerkId:', error) + throw error + } } /** diff --git a/src/app/actions/services/pocketbase/organization_app_user_service.ts b/src/app/actions/services/pocketbase/organization_app_user_service.ts new file mode 100644 index 0000000..37eef3c --- /dev/null +++ b/src/app/actions/services/pocketbase/organization_app_user_service.ts @@ -0,0 +1,327 @@ +import { BaseService } from '@/app/actions/services/pocketbase/api_client' +import { + Collections, + OrganizationAppUser, + OrganizationAppUserCreateInput, + OrganizationAppUserUpdateInput, + organizationAppUserCreateSchema, + organizationAppUserSchema, + organizationAppUserUpdateSchema, +} from '@/models/pocketbase' + +// Re-export types for convenience +export type { + OrganizationAppUser, + OrganizationAppUserCreateInput, + OrganizationAppUserUpdateInput, +} + +/** + * Service for OrganizationAppUser-related operations + */ +export class OrganizationAppUserService extends BaseService< + OrganizationAppUser, + OrganizationAppUserCreateInput, + OrganizationAppUserUpdateInput +> { + constructor() { + super( + Collections.ORGANIZATION_APP_USERS, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] + organizationAppUserSchema, + organizationAppUserCreateSchema, + organizationAppUserUpdateSchema + ) + } + + /** + * Find organization-user mappings by user ID + * + * @param appUserId - The AppUser ID + * @returns The organization-user mappings or empty array if not found + */ + async findByAppUserId(appUserId: string): Promise { + try { + const result = await this.getList({ + filter: `appUser = "${appUserId}"`, + }) + + return result.items + } catch (error) { + console.error( + 'Error finding organization-user mappings by appUserId:', + error + ) + return [] + } + } + + /** + * Find organization-user mappings by organization ID + * + * @param organizationId - The Organization ID + * @returns The organization-user mappings or empty array if not found + */ + async findByOrganizationId( + organizationId: string + ): Promise { + try { + const result = await this.getList({ + filter: `organization = "${organizationId}"`, + }) + + return result.items + } catch (error) { + console.error( + 'Error finding organization-user mappings by organizationId:', + error + ) + return [] + } + } + + /** + * Find a specific organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @returns The organization-user mapping or null if not found + */ + async findByAppUserAndOrganization( + appUserId: string, + organizationId: string + ): Promise { + try { + const result = await this.getList({ + filter: `appUser = "${appUserId}" && organization = "${organizationId}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding organization-user mapping:', error) + return null + } + } + + /** + * Check if a specific organization-user mapping exists + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @returns True if the mapping exists + */ + async exists(appUserId: string, organizationId: string): Promise { + try { + const count = await this.getCount( + `appUser = "${appUserId}" && organization = "${organizationId}"` + ) + return count > 0 + } catch (error) { + console.error( + 'Error checking if organization-user mapping exists:', + error + ) + return false + } + } + + /** + * Create or update an organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @param role - The user's role in the organization + * @returns The created or updated mapping + */ + async createOrUpdate( + appUserId: string, + organizationId: string, + role: string = 'member' + ): Promise { + try { + const existing = await this.findByAppUserAndOrganization( + appUserId, + organizationId + ) + + if (existing) { + console.info( + `Updating existing organization mapping for user ${appUserId} in org ${organizationId}` + ) + return this.update(existing.id, { role }, { validateOutput: false }) + } + + console.info( + `Creating new organization mapping for user ${appUserId} in org ${organizationId}` + ) + return this.create( + { + appUser: appUserId, + organization: organizationId, + role, + }, + { validateOutput: false } + ) + } catch (error) { + // If we get a "record already exists" error, try to find it and update it + if ( + error instanceof Error && + error.message.includes('unique constraint') + ) { + console.warn( + `Unique constraint error for user ${appUserId} in org ${organizationId}. Retrying...` + ) + // Wait a moment for eventual consistency + await new Promise(resolve => setTimeout(resolve, 500)) + + // Try to find the existing record again + const retryExisting = await this.findByAppUserAndOrganization( + appUserId, + organizationId + ) + + if (retryExisting) { + console.info( + `Found existing mapping on retry for user ${appUserId} in org ${organizationId}` + ) + return this.update( + retryExisting.id, + { role }, + { validateOutput: false } + ) + } + } + + console.error('Error creating/updating organization-user mapping:', error) + throw error + } + } + + /** + * Delete an organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @returns True if the mapping was deleted + */ + async deleteMapping( + appUserId: string, + organizationId: string + ): Promise { + try { + const mapping = await this.findByAppUserAndOrganization( + appUserId, + organizationId + ) + + if (mapping) { + await this.delete(mapping.id) + return true + } + + return false + } catch (error) { + console.error('Error deleting organization-user mapping:', error) + return false + } + } + + /** + * Get all user roles for an organization + * + * @param organizationId - The Organization ID + * @returns A map of user IDs to roles + */ + async getUserRolesForOrganization( + organizationId: string + ): Promise> { + const mappings = await this.findByOrganizationId(organizationId) + const userRoles = new Map() + + for (const mapping of mappings) { + userRoles.set(mapping.appUser, mapping.role) + } + + return userRoles + } + + /** + * Get all organization roles for a user + * + * @param appUserId - The AppUser ID + * @returns A map of organization IDs to roles + */ + async getOrganizationRolesForUser( + appUserId: string + ): Promise> { + const mappings = await this.findByAppUserId(appUserId) + const orgRoles = new Map() + + for (const mapping of mappings) { + orgRoles.set(mapping.organization, mapping.role) + } + + return orgRoles + } +} + +// Singleton instance +let organizationAppUserServiceInstance: OrganizationAppUserService | null = null + +/** + * Get the OrganizationAppUserService instance + * + * @returns The OrganizationAppUserService instance + */ +export function getOrganizationAppUserService(): OrganizationAppUserService { + if (!organizationAppUserServiceInstance) { + organizationAppUserServiceInstance = new OrganizationAppUserService() + } + return organizationAppUserServiceInstance +} + +/** + * Create or update an organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @param role - The user's role in the organization + * @returns The created or updated mapping + */ +export async function createOrUpdateOrganizationUserMapping( + appUserId: string, + organizationId: string, + role: string = 'member' +): Promise { + return getOrganizationAppUserService().createOrUpdate( + appUserId, + organizationId, + role + ) +} + +/** + * Get all user roles for an organization + * + * @param organizationId - The Organization ID + * @returns A map of user IDs to roles + */ +export async function getUserRolesForOrganization( + organizationId: string +): Promise> { + return getOrganizationAppUserService().getUserRolesForOrganization( + organizationId + ) +} + +/** + * Get all organization roles for a user + * + * @param appUserId - The AppUser ID + * @returns A map of organization IDs to roles + */ +export async function getOrganizationRolesForUser( + appUserId: string +): Promise> { + return getOrganizationAppUserService().getOrganizationRolesForUser(appUserId) +} diff --git a/src/app/actions/services/pocketbase/securityUtils.ts b/src/app/actions/services/pocketbase/securityUtils.ts deleted file mode 100644 index 03eb457..0000000 --- a/src/app/actions/services/pocketbase/securityUtils.ts +++ /dev/null @@ -1,139 +0,0 @@ -'use server' - -import { getPocketBase } from '@/app/actions/services/pocketbase/api_client/client' -import { - SecurityError, - PermissionLevel, - ResourceType, -} from '@/app/actions/services/securyUtilsTools' -import { AppUser } from '@/types/types_pocketbase' -import { auth } from '@clerk/nextjs/server' - -/** - * Validates a user ID against the current authenticated user - * @param userId The user ID to validate - * @throws {SecurityError} If the user ID is invalid or unauthorized - */ -export async function validateCurrentUser(userId?: string): Promise { - // Get Clerk auth context - const { userId: clerkUserId } = await auth() - - if (!clerkUserId) { - throw new SecurityError('Unauthenticated access') - } - - const pb = getPocketBase() - if (!pb) { - throw new SecurityError('Database connection error') - } - - try { - // Find the user by Clerk ID - const user = await pb - .collection('AppUser') - .getFirstListItem(`clerkId=${'"' + clerkUserId + '"'}`) - - // If a specific user ID was provided, verify it matches the current user - if (userId && user.id !== userId) { - throw new SecurityError('Unauthorized access to user data') - } - - return user - } catch (error) { - console.error('User validation error:', error) - throw new SecurityError('Failed to validate user') - } -} - -/** - * Validates organizational access and permissions - * @param organizationId The organization ID to validate - * @param permission The required permission level - * @returns The validated user and organization - * @throws {Error} If access is unauthorized - */ -export async function validateOrganizationAccess( - organizationId: string, - permission: PermissionLevel = PermissionLevel.READ -): Promise<{ user: AppUser; organizationId: string }> { - // Get authenticated user - const user = await validateCurrentUser() - - // Check organization membership - if (user.organizations?.some(org => org.id === organizationId)) { - throw new SecurityError('Unauthorized access to organization data') - } - - // Check permission level - if ( - permission === PermissionLevel.ADMIN && - !user.isAdmin && - user.role !== 'admin' - ) { - throw new SecurityError('Insufficient permissions for this operation') - } - - if ( - permission === PermissionLevel.WRITE && - !user.isAdmin && - user.role !== 'admin' && - user.role !== 'manager' - ) { - throw new SecurityError('Insufficient permissions for this operation') - } - - return { organizationId, user } -} - -/** - * Validates resource access (equipment, project, assignment) - * @param resourceType The type of resource - * @param resourceId The resource ID - * @param permission The required permission level - * @returns The validated user and organization ID - * @throws {Error} If access is unauthorized - */ -export async function validateResourceAccess( - resourceType: ResourceType, - resourceId: string, - permission: PermissionLevel = PermissionLevel.READ -): Promise<{ user: AppUser; organizationId: string }> { - const pb = getPocketBase() - if (!pb) { - throw new SecurityError('Database connection error') - } - - try { - // Fetch the resource to check organization membership - const resource = await pb.collection(resourceType).getOne(resourceId) - - // Now validate organization access with the required permission - return validateOrganizationAccess(resource.organization, permission) - } catch (error) { - console.error( - `Resource validation error (${resourceType}/${resourceId}):`, - error - ) - throw new SecurityError('Failed to validate resource access') - } -} - -/** - * Creates a secure organization filter - * Ensures that all queries include organization-level filtering - * @param organizationId The organization ID to filter by - * @param additionalFilter Optional additional filter expression - * @returns A complete filter string with organization filtering - */ -export async function createOrganizationFilter( - organizationId: string, - additionalFilter?: string -): Promise { - const orgFilter = `organization="${organizationId}"` - - if (!additionalFilter) { - return orgFilter - } - - return `${orgFilter} && (${additionalFilter})` -} diff --git a/src/app/actions/services/pocketbase/security_utils.ts b/src/app/actions/services/pocketbase/security_utils.ts new file mode 100644 index 0000000..1f0f836 --- /dev/null +++ b/src/app/actions/services/pocketbase/security_utils.ts @@ -0,0 +1,126 @@ +import { getAppUserService } from '@/app/actions/services/pocketbase/app_user_service' +import { AppUser } from '@/models/pocketbase' +import { currentUser } from '@clerk/nextjs/server' + +import { getOrganizationAppUserService } from './organization_app_user_service' + +/** + * Security error class for authentication and authorization errors + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message) + this.name = 'SecurityError' + } +} + +/** + * Permission levels for authorization + */ +export enum PermissionLevel { + ADMIN = 'admin', + READ = 'read', + WRITE = 'write', +} + +/** + * Validates that the current user is authenticated + * @returns The authenticated user + * @throws {SecurityError} If the user is not authenticated + */ +export async function validateCurrentUser(): Promise { + try { + // Get the current user from Clerk + const user = await currentUser() + + if (!user?.id) { + throw new SecurityError('Authentication required') + } + + // Get the user from PocketBase + const userService = getAppUserService() + const pbUser = await userService.findByClerkId(user.id) + + if (!pbUser) { + throw new SecurityError('User not found in database') + } + + return pbUser + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + console.error('Authentication error:', error) + throw new SecurityError('Authentication failed') + } +} + +/** + * Validates that the current user has access to the specified organization with required permission level + * @param organizationId The organization ID to validate + * @param requiredPermission The required permission level + * @returns The validated user and organization ID + * @throws {SecurityError} If access is unauthorized + */ +export async function validateOrganizationAccess( + organizationId: string, + requiredPermission: PermissionLevel = PermissionLevel.READ +): Promise<{ user: AppUser; organizationId: string }> { + // First validate the user is authenticated + const user = await validateCurrentUser() + + // Get the organization-user service + const orgUserService = getOrganizationAppUserService() + + // Get the user's role in this organization + const mapping = await orgUserService.findByAppUserAndOrganization( + user.id, + organizationId + ) + + if (!mapping) { + throw new SecurityError('Unauthorized access to organization data') + } + + const role = mapping.role + + // Check permission level based on role + if (requiredPermission === PermissionLevel.ADMIN && role !== 'admin') { + throw new SecurityError('Admin permission required for this operation') + } + + if ( + requiredPermission === PermissionLevel.WRITE && + !['admin', 'manager'].includes(role) + ) { + throw new SecurityError('Write permission required for this operation') + } + + // If we reach here, the user has the required permission + return { organizationId, user } +} + +/** + * Higher-order function that wraps a function with organization access validation + * @param fn The function to wrap + * @param permissionLevel The permission level required + * @returns The wrapped function + */ +export function withOrganizationAccess< + TArgs extends [string, ...unknown[]], + TReturn, +>( + fn: (...args: TArgs) => Promise, + permissionLevel: PermissionLevel = PermissionLevel.READ +): (...args: TArgs) => Promise { + return async (...args: TArgs): Promise => { + // The first argument should be the organization ID + const organizationId = args[0] + + // Validate the organization access + await validateOrganizationAccess(organizationId, permissionLevel) + + // Call the original function + return fn(...args) + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 3241d99..1add541 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -42,7 +42,7 @@ export default clerkMiddleware(async (auth, req) => { try { // Get auth data const authAwaited = await auth() - + console.log('authAwaited', authAwaited) // Handle protected routes - redirect to sign-in if not authenticated if (isProtectedRoute(req) && !authAwaited.userId) { return NextResponse.redirect(new URL('/sign-in', req.url)) @@ -61,6 +61,8 @@ export default clerkMiddleware(async (auth, req) => { // Only proceed with sync if we have both userId and orgId if (isProtectedRoute(req) && authAwaited.userId && authAwaited.orgId) { try { + console.log('Ensuring user and org sync') + console.log(authAwaited.userId, authAwaited.orgId) await ensureUserAndOrgSync(authAwaited.userId, authAwaited.orgId) } catch (syncError) { console.error('Sync error in middleware:', syncError) diff --git a/src/models/pocketbase/app-user.model.ts b/src/models/pocketbase/app-user.model.ts index 15d384a..d78bf24 100644 --- a/src/models/pocketbase/app-user.model.ts +++ b/src/models/pocketbase/app-user.model.ts @@ -131,7 +131,12 @@ export const appUserSchema = baseRecordSchema.extend({ }), metadata: appUserMetadataSchema, name: z.string().optional().or(z.literal('')), - organizations: z.string().optional().or(z.literal('')), + organizations: z + .union([z.string(), z.array(z.any())]) + .optional() + .transform(val => { + return typeof val === 'string' ? val : '' + }), role: z.string().optional().or(z.literal('')), verified: z.boolean().default(false), }) diff --git a/src/models/pocketbase/collections.model.ts b/src/models/pocketbase/collections.model.ts index ece0dce..a5f4e13 100644 --- a/src/models/pocketbase/collections.model.ts +++ b/src/models/pocketbase/collections.model.ts @@ -8,6 +8,7 @@ export enum Collections { ASSIGNMENTS = 'Assignment', EQUIPMENT = 'Equipment', IMAGES = 'Image', + ORGANIZATION_APP_USERS = 'OrganizationAppUser', ORGANIZATIONS = 'Organization', PROJECTS = 'Project', } diff --git a/src/models/pocketbase/collections.ts b/src/models/pocketbase/collections.ts new file mode 100644 index 0000000..17cc9b3 --- /dev/null +++ b/src/models/pocketbase/collections.ts @@ -0,0 +1,13 @@ +/** + * Collection names enum + */ +export enum Collections { + ACTIVITY_LOGS = 'ActivityLog', + APP_USERS = 'AppUser', + ASSIGNMENTS = 'Assignment', + EQUIPMENT = 'Equipment', + IMAGES = 'Images', + ORGANIZATION_APP_USERS = 'OrganizationAppUser', + ORGANIZATIONS = 'Organization', + PROJECTS = 'Project', +} diff --git a/src/models/pocketbase/index.ts b/src/models/pocketbase/index.ts index ef3eb28..1a30f50 100644 --- a/src/models/pocketbase/index.ts +++ b/src/models/pocketbase/index.ts @@ -15,6 +15,12 @@ export * from '@/models/pocketbase/app-user.model' export * from '@/models/pocketbase/equipment.model' export * from '@/models/pocketbase/organization.model' +// OrganizationAppUser +export * from '@/models/pocketbase/organization-app-user.model' + +// AppUser +export * from '@/models/pocketbase/app-user.model' + // Add other entity models as they are created: // export * from './project.model' // export * from './assignment.model' diff --git a/src/models/pocketbase/organization-app-user.model.ts b/src/models/pocketbase/organization-app-user.model.ts new file mode 100644 index 0000000..5ddd0eb --- /dev/null +++ b/src/models/pocketbase/organization-app-user.model.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' + +import { + BaseRecord, + baseRecordSchema, + createServiceSchemas, +} from './base.model' + +/** + * ======================================== + * ORGANIZATION APP USER TYPES + * ======================================== + */ + +/** + * OrganizationAppUser record interface + */ +export interface OrganizationAppUser extends BaseRecord { + organization: string + appUser: string + role: string +} + +/** + * Type definition for organization app user creation input + */ +export interface OrganizationAppUserCreateInput { + organization: string + appUser: string + role?: string +} + +/** + * Type definition for organization app user update input + */ +export type OrganizationAppUserUpdateInput = + Partial + +/** + * ======================================== + * ORGANIZATION APP USER SCHEMAS + * ======================================== + */ + +/** + * OrganizationAppUser schema for validation + */ +export const organizationAppUserSchema = baseRecordSchema.extend({ + appUser: z.string(), + organization: z.string(), + role: z.string().optional().or(z.literal('')), +}) + +/** + * Generated schemas for organization app user CRUD operations + */ +const { createSchema, updateSchema } = createServiceSchemas( + organizationAppUserSchema +) + +/** + * Schema for organization app user creation + */ +export const organizationAppUserCreateSchema = createSchema.transform(data => { + // Ensure all optional fields have default values + return { + ...data, + role: data.role || 'member', + } +}) as z.ZodType + +/** + * Schema for organization app user updates + */ +export const organizationAppUserUpdateSchema = + updateSchema as z.ZodType From baec540e9424f4e897680d87ceaf2e145ad8e7e0 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Fri, 4 Apr 2025 17:48:34 +0200 Subject: [PATCH 60/73] chore(deps): update package versions - Bump @clerk/nextjs to 6.13.0 - Update framer-motion to 12.6.3 - Upgrade lucide-react to 0.487.0 - Increase svix version to 1.63.1 - Update tailwind-merge to 3.1.0 - Upgrade @tailwindcss/postcss to 4.1.2 - Bump @types/node, react, and react-dom for compatibility - Update eslint plugins and typescript-eslint packages --- package.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 3c325bf..dc5cccc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "prepare": "husky install" }, "dependencies": { - "@clerk/nextjs": "6.12.12", + "@clerk/nextjs": "6.13.0", "@eslint/js": "9.23.0", "@headlessui/react": "2.2.0", "@heroicons/react": "2.2.0", @@ -34,17 +34,17 @@ "clsx": "2.1.1", "crypto": "1.0.1", "dayjs": "1.11.13", - "framer-motion": "12.6.2", + "framer-motion": "12.6.3", "heroicons": "2.2.0", - "lucide-react": "0.485.0", + "lucide-react": "0.487.0", "next": "15.2.4", "pocketbase": "0.25.2", "postcss": "8.5.3", "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "svix": "1.62.0", - "tailwind-merge": "3.0.2", + "svix": "1.63.1", + "tailwind-merge": "3.1.0", "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3" @@ -52,24 +52,24 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@next/eslint-plugin-next": "15.2.4", - "@tailwindcss/postcss": "4.0.17", - "@types/node": "22.13.14", - "@types/react": "19.0.12", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", + "@tailwindcss/postcss": "4.1.2", + "@types/node": "22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.1", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", "eslint": "9.23.0", "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.1", - "eslint-plugin-prettier": "5.2.5", - "eslint-plugin-react": "7.37.4", + "eslint-plugin-perfectionist": "4.11.0", + "eslint-plugin-prettier": "5.2.6", + "eslint-plugin-react": "7.37.5", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "4.0.17", + "tailwindcss": "4.1.2", "tailwindcss-animate": "1.0.7", "typescript": "5.8.2", - "typescript-eslint": "8.28.0" + "typescript-eslint": "8.29.0" } } From ccd1ebcb285d47e79fb8f55fa575e5d4017b849c Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Fri, 4 Apr 2025 17:48:48 +0200 Subject: [PATCH 61/73] feat(deps): update package versions for improvements - Upgrade @clerk/nextjs to version 6.13.0 - Update framer-motion to version 12.6.3 - Bump lucide-react to version 0.487.0 - Refresh svix to version 1.63.1 - Update tailwind-merge to version 3.1.0 - Upgrade typescript-eslint packages to latest versions (8.29.x) - Refresh other dependencies for better compatibility and performance --- bun.lock | 190 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 117 insertions(+), 73 deletions(-) diff --git a/bun.lock b/bun.lock index 5125a21..1fa16ba 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "for-tooling", "dependencies": { - "@clerk/nextjs": "6.12.12", + "@clerk/nextjs": "6.13.0", "@eslint/js": "9.23.0", "@headlessui/react": "2.2.0", "@heroicons/react": "2.2.0", @@ -21,17 +21,17 @@ "clsx": "2.1.1", "crypto": "1.0.1", "dayjs": "1.11.13", - "framer-motion": "12.6.2", + "framer-motion": "12.6.3", "heroicons": "2.2.0", - "lucide-react": "0.485.0", + "lucide-react": "0.487.0", "next": "15.2.4", "pocketbase": "0.25.2", "postcss": "8.5.3", "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "svix": "1.62.0", - "tailwind-merge": "3.0.2", + "svix": "1.63.1", + "tailwind-merge": "3.1.0", "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3", @@ -39,40 +39,40 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@next/eslint-plugin-next": "15.2.4", - "@tailwindcss/postcss": "4.0.17", - "@types/node": "22.13.14", - "@types/react": "19.0.12", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", + "@tailwindcss/postcss": "4.1.2", + "@types/node": "22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.1", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", "eslint": "9.23.0", "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.1", - "eslint-plugin-prettier": "5.2.5", - "eslint-plugin-react": "7.37.4", + "eslint-plugin-perfectionist": "4.11.0", + "eslint-plugin-prettier": "5.2.6", + "eslint-plugin-react": "7.37.5", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "4.0.17", + "tailwindcss": "4.1.2", "tailwindcss-animate": "1.0.7", "typescript": "5.8.2", - "typescript-eslint": "8.28.0", + "typescript-eslint": "8.29.0", }, }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@clerk/backend": ["@clerk/backend@1.25.8", "", { "dependencies": { "@clerk/shared": "^3.2.3", "@clerk/types": "^4.50.1", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" } }, "sha512-DmIc5pNQeTLHLCLN8ajcNhYNCfqmvwSwyGqr5aCHiJdWqGb9DGaws7PXU9btBiXVbI+NK/CJwjGv09+2rGpgAg=="], + "@clerk/backend": ["@clerk/backend@1.26.0", "", { "dependencies": { "@clerk/shared": "^3.3.0", "@clerk/types": "^4.50.2", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" }, "peerDependencies": { "svix": "^1.62.0" }, "optionalPeers": ["svix"] }, "sha512-ioZBMnwm4DD8IVPGDjFW3wkyn1JTMvTlsmdHGYsjdbXLtbRFVRJelAIMMGLcSmqMgzTKxnrJSOz8PxPjSMUFtw=="], - "@clerk/clerk-react": ["@clerk/clerk-react@5.25.5", "", { "dependencies": { "@clerk/shared": "^3.2.3", "@clerk/types": "^4.50.1", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-euG4T9EaN4af4YH7N8Fl6hIKnXQl+KSZv1WTLgD4KP90hSpVTMPkhdWeOiRFpNQ5I6WwtkaUPY16nce5y/NTQA=="], + "@clerk/clerk-react": ["@clerk/clerk-react@5.25.6", "", { "dependencies": { "@clerk/shared": "^3.3.0", "@clerk/types": "^4.50.2", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-QXISFiW4xI96nIE8MEfqpy+ISjtfYa2wWYeS8Nyo+K34dK1aNpawpTopRKRirqUy2QRSF/yXaCY9IF/v22XlJg=="], - "@clerk/nextjs": ["@clerk/nextjs@6.12.12", "", { "dependencies": { "@clerk/backend": "^1.25.8", "@clerk/clerk-react": "^5.25.5", "@clerk/shared": "^3.2.3", "@clerk/types": "^4.50.1", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-V1Vb1a5pTZArNrCy/YvpXCFQZsrRb54G+crzZ55kiuqvPaVGPguEoSCqjoaJ1RolyagXMhLKvht3Te6DYMSZEg=="], + "@clerk/nextjs": ["@clerk/nextjs@6.13.0", "", { "dependencies": { "@clerk/backend": "^1.26.0", "@clerk/clerk-react": "^5.25.6", "@clerk/shared": "^3.3.0", "@clerk/types": "^4.50.2", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-xikvFU8JWBtbgh3pe76yWrlOQzIACdUNsinHP06qeRJIIg8yci8sYa93ASjd0TNPzj9cInF+owMj6mDQw7HZ5Q=="], - "@clerk/shared": ["@clerk/shared@3.2.3", "", { "dependencies": { "@clerk/types": "^4.50.1", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.7.0", "swr": "^2.2.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-F8P7SqpcaLTV/wwCB3/1AkboO3YqFjb7qS6GoSDtVTFHMfpHJgHKhZ0vUBQFaLh/8ZV1kyRuiI/hrrbwIOF1EQ=="], + "@clerk/shared": ["@clerk/shared@3.3.0", "", { "dependencies": { "@clerk/types": "^4.50.2", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.8.1", "swr": "^2.3.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-hO1M5aRMzJVqkw6lWJ7NFVG5hWEnTZBUZGeHMYRwSPQzQNsgqsRMvpmaJO0Y2o0HNk50PpwZHaiFHcghUpfMiw=="], - "@clerk/types": ["@clerk/types@4.50.1", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-GwsW/6LPHavHghh2QpmDbhyIuDP61OYV0T6x5hnjgAxjfexpRymbewR7Qez7H4kOo4gtnCNUrgTZ6nyresLEEg=="], + "@clerk/types": ["@clerk/types@4.50.2", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-4m1RlV/Dl3ZGW5FAXmKfdCbhF7uTDDvaADZH1F6L3d3lRBdI6i7GppK1KqscOSgoC8OwJqGaiDVUPsg+Pp8usg=="], "@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="], @@ -260,33 +260,33 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.0.17", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "tailwindcss": "4.0.17" } }, "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.2", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.2" } }, "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.0.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.0.17", "@tailwindcss/oxide-darwin-arm64": "4.0.17", "@tailwindcss/oxide-darwin-x64": "4.0.17", "@tailwindcss/oxide-freebsd-x64": "4.0.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.0.17", "@tailwindcss/oxide-linux-arm64-musl": "4.0.17", "@tailwindcss/oxide-linux-x64-gnu": "4.0.17", "@tailwindcss/oxide-linux-x64-musl": "4.0.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.0.17", "@tailwindcss/oxide-win32-x64-msvc": "4.0.17" } }, "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.2", "@tailwindcss/oxide-darwin-arm64": "4.1.2", "@tailwindcss/oxide-darwin-x64": "4.1.2", "@tailwindcss/oxide-freebsd-x64": "4.1.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.2", "@tailwindcss/oxide-linux-arm64-musl": "4.1.2", "@tailwindcss/oxide-linux-x64-gnu": "4.1.2", "@tailwindcss/oxide-linux-x64-musl": "4.1.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.2", "@tailwindcss/oxide-win32-x64-msvc": "4.1.2" } }, "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.0.17", "", { "os": "android", "cpu": "arm64" }, "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.2", "", { "os": "android", "cpu": "arm64" }, "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.0.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.0.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.0.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.0.17", "", { "os": "linux", "cpu": "arm" }, "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.2", "", { "os": "linux", "cpu": "arm" }, "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.0.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.0.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.0.17", "", { "os": "linux", "cpu": "x64" }, "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.0.17", "", { "os": "linux", "cpu": "x64" }, "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.0.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.0.17", "", { "os": "win32", "cpu": "x64" }, "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.0.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.0.17", "@tailwindcss/oxide": "4.0.17", "lightningcss": "1.29.2", "postcss": "^8.4.41", "tailwindcss": "4.0.17" } }, "sha512-qeJbRTB5FMZXmuJF+eePd235EGY6IyJZF0Bh0YM6uMcCI4L9Z7dy+lPuLAhxOJzxnajsbjPoDAKOuAqZRtf1PQ=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.2", "@tailwindcss/oxide": "4.1.2", "postcss": "^8.4.41", "tailwindcss": "4.1.2" } }, "sha512-vgkMo6QRhG6uv97im6Y4ExDdq71y9v2IGZc+0wn7lauQFYJM/1KdUVhrOkexbUso8tUsMOWALxyHVkQEbsM7gw=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.4", "", { "dependencies": { "@tanstack/virtual-core": "3.13.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jPWC3BXvVLHsMX67NEHpJaZ+/FySoNxFfBEiF4GBc1+/nVwdRm+UcSCYnKP3pXQr0eEsDpXi/PQZhNfJNopH0g=="], @@ -300,27 +300,27 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@22.13.14", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w=="], + "@types/node": ["@types/node@22.14.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA=="], - "@types/react": ["@types/react@19.0.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA=="], + "@types/react": ["@types/react@19.1.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w=="], - "@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="], + "@types/react-dom": ["@types/react-dom@19.1.1", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.28.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/type-utils": "8.28.0", "@typescript-eslint/utils": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.29.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/type-utils": "8.29.0", "@typescript-eslint/utils": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.28.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.29.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/types": "8.29.0", "@typescript-eslint/typescript-estree": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0" } }, "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.28.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.29.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.29.0", "@typescript-eslint/utils": "8.29.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.29.0", "", {}, "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.29.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/types": "8.29.0", "@typescript-eslint/typescript-estree": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg=="], "acorn": ["acorn@8.14.1", "", { "bin": "bin/acorn" }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], @@ -486,11 +486,11 @@ "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], - "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@4.10.1", "", { "dependencies": { "@typescript-eslint/types": "^8.26.0", "@typescript-eslint/utils": "^8.26.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": ">=8.45.0" } }, "sha512-GXwFfL47RfBLZRGQdrvGZw9Ali2T2GPW8p4Gyj2fyWQ9396R/HgJMf0m9kn7D6WXRwrINfTDGLS+QYIeok9qEg=="], + "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@4.11.0", "", { "dependencies": { "@typescript-eslint/types": "^8.29.0", "@typescript-eslint/utils": "^8.29.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": ">=8.45.0" } }, "sha512-5s+ehXydnLPQpLDj5mJ0CnYj2fQe6v6gKA3tS+FZVBLzwMOh8skH+l+1Gni08rG0SdEcNhJyjQp/mEkDYK8czw=="], - "eslint-plugin-prettier": ["eslint-plugin-prettier@5.2.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.10.2" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.2.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.11.0" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ=="], - "eslint-plugin-react": ["eslint-plugin-react@7.37.4", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ=="], + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], @@ -538,7 +538,7 @@ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], - "framer-motion": ["framer-motion@12.6.2", "", { "dependencies": { "motion-dom": "^12.6.1", "motion-utils": "^12.5.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-7LgPRlPs5aG8UxeZiMCMZz8firC53+2+9TnWV22tuSi38D3IFRxHRUqOREKckAkt6ztX+Dn6weLcatQilJTMcg=="], + "framer-motion": ["framer-motion@12.6.3", "", { "dependencies": { "motion-dom": "^12.6.3", "motion-utils": "^12.6.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-2hsqknz23aloK85bzMc9nSR2/JP+fValQ459ZTVElFQ0xgwR2YqNjYSuDZdFBPOwVCt4Q9jgyTt6hg6sVOALzw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -712,7 +712,7 @@ "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], - "lucide-react": ["lucide-react@0.485.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NvyQJ0LKyyCxL23nPKESlr/jmz8r7fJO1bkuptSNYSy0s8VVj4ojhX0YAgmE1e0ewfxUZjIlZpvH+otfTnla8Q=="], + "lucide-react": ["lucide-react@0.487.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw=="], "map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="], @@ -726,9 +726,9 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "motion-dom": ["motion-dom@12.6.1", "", { "dependencies": { "motion-utils": "^12.5.0" } }, "sha512-8XVsriTUEVOepoIDgE/LDGdg7qaKXWdt+wQA/8z0p8YzJDLYL8gbimZ3YkCLlj7bB2i/4UBD/g+VO7y9ZY0zHQ=="], + "motion-dom": ["motion-dom@12.6.3", "", { "dependencies": { "motion-utils": "^12.6.3" } }, "sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA=="], - "motion-utils": ["motion-utils@12.5.0", "", {}, "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA=="], + "motion-utils": ["motion-utils@12.6.3", "", {}, "sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -756,7 +756,7 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - "object.entries": ["object.entries@1.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ=="], + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], @@ -906,19 +906,19 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svix": ["svix@1.62.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "svix-fetch": "^3.0.0", "url-parse": "^1.5.10" } }, "sha512-Ia1s78JVcK0SXEzULNln4Vqi8LN3l+9rEs7d10XoOtg1c/dY2r59W4qRwd77BVbstW2v3HmsSqXkeZ6eZktnhA=="], + "svix": ["svix@1.63.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "svix-fetch": "^3.0.0", "url-parse": "^1.5.10" } }, "sha512-1NdTJ4YI4jd8vbRLjGNg8ZCFlIb+t2iTtt1ddm+DsNKQC4GkhgjDMi7wRcXiWraBonYSlr/KARSknUW6iLM4fA=="], "svix-fetch": ["svix-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw=="], "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], - "synckit": ["synckit@0.10.3", "", { "dependencies": { "@pkgr/core": "^0.2.0", "tslib": "^2.8.1" } }, "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ=="], + "synckit": ["synckit@0.11.2", "", { "dependencies": { "@pkgr/core": "^0.2.0", "tslib": "^2.8.1" } }, "sha512-1IUffI8zZ8qUMB3NUJIjk0RpLroG/8NkQDAWH1NbB2iJ0/5pn3M8rxfNzMz4GH9OnYaGYn31LEDSXJp/qIlxgA=="], "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], - "tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="], + "tailwind-merge": ["tailwind-merge@3.1.0", "", {}, "sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q=="], - "tailwindcss": ["tailwindcss@4.0.17", "", {}, "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw=="], + "tailwindcss": ["tailwindcss@4.1.2", "", {}, "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -952,11 +952,11 @@ "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], - "typescript-eslint": ["typescript-eslint@8.28.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.28.0", "@typescript-eslint/parser": "8.28.0", "@typescript-eslint/utils": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-jfZtxJoHm59bvoCMYCe2BM0/baMswRhMmYhy+w6VfcyHrjxZ0OJe0tGasydCpIpA+A/WIJhTyZfb3EtwNC/kHQ=="], + "typescript-eslint": ["typescript-eslint@8.29.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/parser": "8.29.0", "@typescript-eslint/utils": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], @@ -1006,6 +1006,12 @@ "debug/ms": ["ms@2.1.3", "", { "bundled": true }, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.28.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/type-utils": "8.28.0", "@typescript-eslint/utils": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg=="], + + "eslint-config-next/@typescript-eslint/parser": ["@typescript-eslint/parser@8.28.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ=="], + + "eslint-config-next/eslint-plugin-react": ["eslint-plugin-react@7.37.4", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -1014,10 +1020,6 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "eslint-plugin-perfectionist/@typescript-eslint/types": ["@typescript-eslint/types@8.26.1", "", {}, "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils": ["@typescript-eslint/utils@8.26.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.26.1", "@typescript-eslint/types": "8.26.1", "@typescript-eslint/typescript-estree": "8.26.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fdir/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -1034,22 +1036,64 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1" } }, "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.28.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="], + + "eslint-config-next/eslint-plugin-react/object.entries": ["object.entries@1.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg=="], + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], } } From b8be00565e6cbc54bf7169fb8d5db0a6df2acf98 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Fri, 4 Apr 2025 21:20:22 +0200 Subject: [PATCH 62/73] feat(docs): add core API documentation for table features - Create detailed documentation for Cell, Column, Row, Header, and Table APIs - Include options and properties for each API with examples - Document column features like filtering, ordering, and pinning - Provide usage guidelines for creating tables in various frameworks --- .../tanstack/table-main/docs/api/core/cell.md | 68 +++ .../table-main/docs/api/core/column-def.md | 107 +++++ .../table-main/docs/api/core/column.md | 77 ++++ .../table-main/docs/api/core/header-group.md | 33 ++ .../table-main/docs/api/core/header.md | 243 +++++++++++ .../tanstack/table-main/docs/api/core/row.md | 123 ++++++ .../table-main/docs/api/core/table.md | 385 +++++++++++++++++ .../docs/api/features/column-faceting.md | 46 ++ .../docs/api/features/column-filtering.md | 396 ++++++++++++++++++ .../docs/api/features/column-ordering.md | 70 ++++ .../docs/api/features/column-pinning.md | 266 ++++++++++++ .../docs/api/features/column-sizing.md | 253 +++++++++++ .../docs/api/features/column-visibility.md | 178 ++++++++ .../table-main/docs/api/features/expanding.md | 208 +++++++++ .../table-main/docs/api/features/filters.md | 13 + .../docs/api/features/global-faceting.md | 30 ++ .../docs/api/features/global-filtering.md | 291 +++++++++++++ .../table-main/docs/api/features/grouping.md | 353 ++++++++++++++++ .../docs/api/features/pagination.md | 207 +++++++++ .../table-main/docs/api/features/pinning.md | 11 + .../docs/api/features/row-pinning.md | 138 ++++++ .../docs/api/features/row-selection.md | 230 ++++++++++ .../table-main/docs/api/features/sorting.md | 385 +++++++++++++++++ 23 files changed, 4111 insertions(+) create mode 100644 .cursor/tanstack/table-main/docs/api/core/cell.md create mode 100644 .cursor/tanstack/table-main/docs/api/core/column-def.md create mode 100644 .cursor/tanstack/table-main/docs/api/core/column.md create mode 100644 .cursor/tanstack/table-main/docs/api/core/header-group.md create mode 100644 .cursor/tanstack/table-main/docs/api/core/header.md create mode 100644 .cursor/tanstack/table-main/docs/api/core/row.md create mode 100644 .cursor/tanstack/table-main/docs/api/core/table.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/column-faceting.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/column-filtering.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/column-ordering.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/column-pinning.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/column-sizing.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/column-visibility.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/expanding.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/filters.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/global-faceting.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/global-filtering.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/grouping.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/pagination.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/pinning.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/row-pinning.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/row-selection.md create mode 100644 .cursor/tanstack/table-main/docs/api/features/sorting.md diff --git a/.cursor/tanstack/table-main/docs/api/core/cell.md b/.cursor/tanstack/table-main/docs/api/core/cell.md new file mode 100644 index 0000000..a39ca79 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/cell.md @@ -0,0 +1,68 @@ +--- +title: Cell APIs +--- + +These are **core** options and API properties for all cells. More options and API properties are available for other [table features](../guide/features). + +## Cell API + +All cell objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The unique ID for the cell across the entire table. + +### `getValue` + +```tsx +getValue: () => any +``` + +Returns the value for the cell, accessed via the associated column's accessor key or accessor function. + +### `renderValue` + +```tsx +renderValue: () => any +``` + +Renders the value for a cell the same as `getValue`, but will return the `renderFallbackValue` if no value is found. + +### `row` + +```tsx +row: Row +``` + +The associated Row object for the cell. + +### `column` + +```tsx +column: Column +``` + +The associated Column object for the cell. + +### `getContext` + +```tsx +getContext: () => { + table: Table + column: Column + row: Row + cell: Cell + getValue: () => TTValue + renderValue: () => TTValue | null +} +``` + +Returns the rendering context (or props) for cell-based components like cells and aggregated cells. Use these props with your framework's `flexRender` utility to render these using the template of your choice: + +```tsx +flexRender(cell.column.columnDef.cell, cell.getContext()) +``` diff --git a/.cursor/tanstack/table-main/docs/api/core/column-def.md b/.cursor/tanstack/table-main/docs/api/core/column-def.md new file mode 100644 index 0000000..3522e9d --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/column-def.md @@ -0,0 +1,107 @@ +--- +title: ColumnDef APIs +--- + +Column definitions are plain objects with the following options: + +## Options + +### `id` + +```tsx +id: string +``` + +The unique identifier for the column. + +> 🧠 A column ID is optional when: +> +> - An accessor column is created with an object key accessor +> - The column header is defined as a string + +### `accessorKey` + +```tsx +accessorKey?: string & typeof TData +``` + +The key of the row object to use when extracting the value for the column. + +### `accessorFn` + +```tsx +accessorFn?: (originalRow: TData, index: number) => any +``` + +The accessor function to use when extracting the value for the column from each row. + +### `columns` + +```tsx +columns?: ColumnDef[] +``` + +The child column defs to include in a group column. + +### `header` + +```tsx +header?: + | string + | ((props: { + table: Table + header: Header + column: Column + }) => unknown) +``` + +The header to display for the column. If a string is passed, it can be used as a default for the column ID. If a function is passed, it will be passed a props object for the header and should return the rendered header value (the exact type depends on the adapter being used). + +### `footer` + +```tsx +footer?: + | string + | ((props: { + table: Table + header: Header + column: Column + }) => unknown) +``` + +The footer to display for the column. If a function is passed, it will be passed a props object for the footer and should return the rendered footer value (the exact type depends on the adapter being used). + +### `cell` + +```tsx +cell?: + | string + | ((props: { + table: Table + row: Row + column: Column + cell: Cell + getValue: () => any + renderValue: () => any + }) => unknown) +``` + +The cell to display each row for the column. If a function is passed, it will be passed a props object for the cell and should return the rendered cell value (the exact type depends on the adapter being used). + +### `meta` + +```tsx +meta?: ColumnMeta // This interface is extensible via declaration merging. See below! +``` + +The meta data to be associated with the column. We can access it anywhere when the column is available via `column.columnDef.meta`. This type is global to all tables and can be extended like so: + +```tsx +import '@tanstack/react-table' //or vue, svelte, solid, qwik, etc. + +declare module '@tanstack/react-table' { + interface ColumnMeta { + foo: string + } +} +``` diff --git a/.cursor/tanstack/table-main/docs/api/core/column.md b/.cursor/tanstack/table-main/docs/api/core/column.md new file mode 100644 index 0000000..1ca4db8 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/column.md @@ -0,0 +1,77 @@ +--- +title: Column APIs +--- + +These are **core** options and API properties for all columns. More options and API properties are available for other [table features](../guide/features). + +## Column API + +All column objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The resolved unique identifier for the column resolved in this priority: + +- A manual `id` property from the column def +- The accessor key from the column def +- The header string from the column def + +### `depth` + +```tsx +depth: number +``` + +The depth of the column (if grouped) relative to the root column def array. + +### `accessorFn` + +```tsx +accessorFn?: AccessorFn +``` + +The resolved accessor function to use when extracting the value for the column from each row. Will only be defined if the column def has a valid accessor key or function defined. + +### `columnDef` + +```tsx +columnDef: ColumnDef +``` + +The original column def used to create the column. + +### `columns` + +```tsx +type columns = ColumnDef[] +``` + +The child column (if the column is a group column). Will be an empty array if the column is not a group column. + +### `parent` + +```tsx +parent?: Column +``` + +The parent column for this column. Will be undefined if this is a root column. + +### `getFlatColumns` + +```tsx +type getFlatColumns = () => Column[] +``` + +Returns the flattened array of this column and all child/grand-child columns for this column. + +### `getLeafColumns` + +```tsx +type getLeafColumns = () => Column[] +``` + +Returns an array of all leaf-node columns for this column. If a column has no children, it is considered the only leaf-node column. diff --git a/.cursor/tanstack/table-main/docs/api/core/header-group.md b/.cursor/tanstack/table-main/docs/api/core/header-group.md new file mode 100644 index 0000000..24b5a5b --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/header-group.md @@ -0,0 +1,33 @@ +--- +title: HeaderGroup APIs +--- + +These are **core** options and API properties for all header groups. More options and API properties may be available for other [table features](../guide/features). + +## Header Group API + +All header group objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The unique identifier for the header group. + +### `depth` + +```tsx +depth: number +``` + +The depth of the header group, zero-indexed based. + +### `headers` + +```tsx +type headers = Header[] +``` + +An array of [Header](../api/core/header) objects that belong to this header group diff --git a/.cursor/tanstack/table-main/docs/api/core/header.md b/.cursor/tanstack/table-main/docs/api/core/header.md new file mode 100644 index 0000000..4cb3ca7 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/header.md @@ -0,0 +1,243 @@ +--- +title: Header APIs +--- + +These are **core** options and API properties for all headers. More options and API properties may be available for other [table features](../guide/features). + +## Header API + +All header objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The unique identifier for the header. + +### `index` + +```tsx +index: number +``` + +The index for the header within the header group. + +### `depth` + +```tsx +depth: number +``` + +The depth of the header, zero-indexed based. + +### `column` + +```tsx +column: Column +``` + +The header's associated [Column](../api/core/column) object + +### `headerGroup` + +```tsx +headerGroup: HeaderGroup +``` + +The header's associated [HeaderGroup](../api/core/header-group) object + +### `subHeaders` + +```tsx +type subHeaders = Header[] +``` + +The header's hierarchical sub/child headers. Will be empty if the header's associated column is a leaf-column. + +### `colSpan` + +```tsx +colSpan: number +``` + +The col-span for the header. + +### `rowSpan` + +```tsx +rowSpan: number +``` + +The row-span for the header. + +### `getLeafHeaders` + +```tsx +type getLeafHeaders = () => Header[] +``` + +Returns the leaf headers hierarchically nested under this header. + +### `isPlaceholder` + +```tsx +isPlaceholder: boolean +``` + +A boolean denoting if the header is a placeholder header + +### `placeholderId` + +```tsx +placeholderId?: string +``` + +If the header is a placeholder header, this will be a unique header ID that does not conflict with any other headers across the table + +### `getContext` + +```tsx +getContext: () => { + table: Table + header: Header + column: Column +} +``` + +Returns the rendering context (or props) for column-based components like headers, footers and filters. Use these props with your framework's `flexRender` utility to render these using the template of your choice: + +```tsx +flexRender(header.column.columnDef.header, header.getContext()) +``` + +## Table API + +### `getHeaderGroups` + +```tsx +type getHeaderGroups = () => HeaderGroup[] +``` + +Returns all header groups for the table. + +### `getLeftHeaderGroups` + +```tsx +type getLeftHeaderGroups = () => HeaderGroup[] +``` + +If pinning, returns the header groups for the left pinned columns. + +### `getCenterHeaderGroups` + +```tsx +type getCenterHeaderGroups = () => HeaderGroup[] +``` + +If pinning, returns the header groups for columns that are not pinned. + +### `getRightHeaderGroups` + +```tsx +type getRightHeaderGroups = () => HeaderGroup[] +``` + +If pinning, returns the header groups for the right pinned columns. + +### `getFooterGroups` + +```tsx +type getFooterGroups = () => HeaderGroup[] +``` + +Returns all footer groups for the table. + +### `getLeftFooterGroups` + +```tsx +type getLeftFooterGroups = () => HeaderGroup[] +``` + +If pinning, returns the footer groups for the left pinned columns. + +### `getCenterFooterGroups` + +```tsx +type getCenterFooterGroups = () => HeaderGroup[] +``` + +If pinning, returns the footer groups for columns that are not pinned. + +### `getRightFooterGroups` + +```tsx +type getRightFooterGroups = () => HeaderGroup[] +``` + +If pinning, returns the footer groups for the right pinned columns. + +### `getFlatHeaders` + +```tsx +type getFlatHeaders = () => Header[] +``` + +Returns headers for all columns in the table, including parent headers. + +### `getLeftFlatHeaders` + +```tsx +type getLeftFlatHeaders = () => Header[] +``` + +If pinning, returns headers for all left pinned columns in the table, including parent headers. + +### `getCenterFlatHeaders` + +```tsx +type getCenterFlatHeaders = () => Header[] +``` + +If pinning, returns headers for all columns that are not pinned, including parent headers. + +### `getRightFlatHeaders` + +```tsx +type getRightFlatHeaders = () => Header[] +``` + +If pinning, returns headers for all right pinned columns in the table, including parent headers. + +### `getLeafHeaders` + +```tsx +type getLeafHeaders = () => Header[] +``` + +Returns headers for all leaf columns in the table, (not including parent headers). + +### `getLeftLeafHeaders` + +```tsx +type getLeftLeafHeaders = () => Header[] +``` + +If pinning, returns headers for all left pinned leaf columns in the table, (not including parent headers). + +### `getCenterLeafHeaders` + +```tsx +type getCenterLeafHeaders = () => Header[] +``` + +If pinning, returns headers for all columns that are not pinned, (not including parent headers). + +### `getRightLeafHeaders` + +```tsx +type getRightLeafHeaders = () => Header[] +``` + +If pinning, returns headers for all right pinned leaf columns in the table, (not including parent headers). diff --git a/.cursor/tanstack/table-main/docs/api/core/row.md b/.cursor/tanstack/table-main/docs/api/core/row.md new file mode 100644 index 0000000..da74859 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/row.md @@ -0,0 +1,123 @@ +--- +title: Row APIs +--- + +These are **core** options and API properties for all rows. More options and API properties are available for other [table features](../guide/features). + +## Row API + +All row objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The resolved unique identifier for the row resolved via the `options.getRowId` option. Defaults to the row's index (or relative index if it is a subRow) + +### `depth` + +```tsx +depth: number +``` + +The depth of the row (if nested or grouped) relative to the root row array. + +### `index` + +```tsx +index: number +``` + +The index of the row within its parent array (or the root data array) + +### `original` + +```tsx +original: TData +``` + +The original row object provided to the table. + +> 🧠 If the row is a grouped row, the original row object will be the first original in the group. + +### `parentId` + +```tsx +parentId?: string +``` + +If nested, this row's parent row id. + +### `getValue` + +```tsx +getValue: (columnId: string) => TValue +``` + +Returns the value from the row for a given columnId + +### `renderValue` + +```tsx +renderValue: (columnId: string) => TValue +``` + +Renders the value from the row for a given columnId, but will return the `renderFallbackValue` if no value is found. + +### `getUniqueValues` + +```tsx +getUniqueValues: (columnId: string) => TValue[] +``` + +Returns a unique array of values from the row for a given columnId. + +### `subRows` + +```tsx +type subRows = Row[] +``` + +An array of subRows for the row as returned and created by the `options.getSubRows` option. + +### `getParentRow` + +```tsx +type getParentRow = () => Row | undefined +``` + +Returns the parent row for the row, if it exists. + +### `getParentRows` + +```tsx +type getParentRows = () => Row[] +``` + +Returns the parent rows for the row, all the way up to a root row. + +### `getLeafRows` + +```tsx +type getLeafRows = () => Row[] +``` + +Returns the leaf rows for the row, not including any parent rows. + +### `originalSubRows` + +```tsx +originalSubRows?: TData[] +``` + +An array of the original subRows as returned by the `options.getSubRows` option. + +### `getAllCells` + +```tsx +type getAllCells = () => Cell[] +``` + +Returns all of the [Cells](../api/core/cell) for the row. diff --git a/.cursor/tanstack/table-main/docs/api/core/table.md b/.cursor/tanstack/table-main/docs/api/core/table.md new file mode 100644 index 0000000..e5cf07c --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/table.md @@ -0,0 +1,385 @@ +--- +title: Table APIs +--- + +## `createAngularTable` / `useReactTable` / `createSolidTable` / `useQwikTable` / `useVueTable` / `createSvelteTable` + +```tsx +type useReactTable = ( + options: TableOptions +) => Table +``` + +These functions are used to create a table. Which one you use depends on which framework adapter you are using. + +## Options + +These are **core** options and API properties for the table. More options and API properties are available for other [table features](../guide/features). + +### `data` + +```tsx +data: TData[] +``` + +The data for the table to display. This array should match the type you provided to `table.setRowType<...>`, but in theory could be an array of anything. It's common for each item in the array to be an object of key/values but this is not required. Columns can access this data via string/index or a functional accessor to return anything they want. + +When the `data` option changes reference (compared via `Object.is`), the table will reprocess the data. Any other data processing that relies on the core data model (such as grouping, sorting, filtering, etc) will also be reprocessed. + +> 🧠 Make sure your `data` option is only changing when you want the table to reprocess. Providing an inline `[]` or constructing the data array as a new object every time you want to render the table will result in a _lot_ of unnecessary re-processing. This can easily go unnoticed in smaller tables, but you will likely notice it in larger tables. + +### `columns` + +```tsx +type columns = ColumnDef[] +``` + +The array of column defs to use for the table. See the [Column Defs Guide](../../docs/guide/column-defs) for more information on creating column definitions. + +### `defaultColumn` + +```tsx +defaultColumn?: Partial> +``` + +Default column options to use for all column defs supplied to the table. This is useful for providing default cell/header/footer renderers, sorting/filtering/grouping options, etc. All column definitions passed to `options.columns` are merged with this default column definition to produce the final column definitions. + +### `initialState` + +```tsx +initialState?: Partial< + VisibilityTableState & + ColumnOrderTableState & + ColumnPinningTableState & + FiltersTableState & + SortingTableState & + ExpandedTableState & + GroupingTableState & + ColumnSizingTableState & + PaginationTableState & + RowSelectionTableState +> +``` + +Use this option to optionally pass initial state to the table. This state will be used when resetting various table states either automatically by the table (eg. `options.autoResetPageIndex`) or via functions like `table.resetRowSelection()`. Most reset function allow you optionally pass a flag to reset to a blank/default state instead of the initial state. + +> 🧠 Table state will not be reset when this object changes, which also means that the initial state object does not need to be stable. + +### `autoResetAll` + +```tsx +autoResetAll?: boolean +``` + +Set this option to override any of the `autoReset...` feature options. + +### `meta` + +```tsx +meta?: TableMeta // This interface is extensible via declaration merging. See below! +``` + +You can pass any object to `options.meta` and access it anywhere the `table` is available via `table.options.meta` This type is global to all tables and can be extended like so: + +```tsx +declare module '@tanstack/table-core' { + interface TableMeta { + foo: string + } +} +``` + +> 🧠 Think of this option as an arbitrary "context" for your table. This is a great way to pass arbitrary data or functions to your table without having to pass it to every thing the table touches. A good example is passing a locale object to your table to use for formatting dates, numbers, etc or even a function that can be used to update editable data like in the [editable-data example](../framework/react/examples/editable-data). + +### `state` + +```tsx +state?: Partial< + VisibilityTableState & + ColumnOrderTableState & + ColumnPinningTableState & + FiltersTableState & + SortingTableState & + ExpandedTableState & + GroupingTableState & + ColumnSizingTableState & + PaginationTableState & + RowSelectionTableState +> +``` + +The `state` option can be used to optionally _control_ part or all of the table state. The state you pass here will merge with and overwrite the internal automatically-managed state to produce the final state for the table. You can also listen to state changes via the `onStateChange` option. + +### `onStateChange` + +```tsx +onStateChange: (updater: Updater) => void +``` + +The `onStateChange` option can be used to optionally listen to state changes within the table. If you provide this options, you will be responsible for controlling and updating the table state yourself. You can provide the state back to the table with the `state` option. + +### `debugAll` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugAll?: boolean +``` + +Set this option to true to output all debugging information to the console. + +### `debugTable` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugTable?: boolean +``` + +Set this option to true to output table debugging information to the console. + +### `debugHeaders` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugHeaders?: boolean +``` + +Set this option to true to output header debugging information to the console. + +### `debugColumns` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugColumns?: boolean +``` + +Set this option to true to output column debugging information to the console. + +### `debugRows` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugRows?: boolean +``` + +Set this option to true to output row debugging information to the console. + +### `_features` + +```tsx +_features?: TableFeature[] +``` + +An array of extra features that you can add to the table instance. + +### `render` + +> ⚠️ This option is only necessary if you are implementing a table adapter. + +```tsx +type render = (template: Renderable, props: TProps) => any +``` + +The `render` option provides a renderer implementation for the table. This implementation is used to turn a table's various column header and cell templates into a result that is supported by the user's framework. + +### `mergeOptions` + +> ⚠️ This option is only necessary if you are implementing a table adapter. + +```tsx +type mergeOptions = (defaultOptions: T, options: Partial) => T +``` + +This option is used to optionally implement the merging of table options. Some framework like solid-js use proxies to track reactivity and usage, so merging reactive objects needs to be handled carefully. This option inverts control of this process to the adapter. + +### `getCoreRowModel` + +```tsx +getCoreRowModel: (table: Table) => () => RowModel +``` + +This required option is a factory for a function that computes and returns the core row model for the table. It is called **once** per table and should return a **new function** which will calculate and return the row model for the table. + +A default implementation is provided via any table adapter's `{ getCoreRowModel }` export. + +### `getSubRows` + +```tsx +getSubRows?: ( + originalRow: TData, + index: number +) => undefined | TData[] +``` + +This optional function is used to access the sub rows for any given row. If you are using nested rows, you will need to use this function to return the sub rows object (or undefined) from the row. + +### `getRowId` + +```tsx +getRowId?: ( + originalRow: TData, + index: number, + parent?: Row +) => string +``` + +This optional function is used to derive a unique ID for any given row. If not provided the rows index is used (nested rows join together with `.` using their grandparents' index eg. `index.index.index`). If you need to identify individual rows that are originating from any server-side operations, it's suggested you use this function to return an ID that makes sense regardless of network IO/ambiguity eg. a userId, taskId, database ID field, etc. + +## Table API + +These properties and methods are available on the table object: + +### `initialState` + +```tsx +initialState: VisibilityTableState & + ColumnOrderTableState & + ColumnPinningTableState & + FiltersTableState & + SortingTableState & + ExpandedTableState & + GroupingTableState & + ColumnSizingTableState & + PaginationTableState & + RowSelectionTableState +``` + +This is the resolved initial state of the table. + +### `reset` + +```tsx +reset: () => void +``` + +Call this function to reset the table state to the initial state. + +### `getState` + +```tsx +getState: () => TableState +``` + +Call this function to get the table's current state. It's recommended to use this function and its state, especially when managing the table state manually. It is the exact same state used internally by the table for every feature and function it provides. + +> 🧠 The state returned by this function is the shallow-merged result of the automatically-managed internal table-state and any manually-managed state passed via `options.state`. + +### `setState` + +```tsx +setState: (updater: Updater) => void +``` + +Call this function to update the table state. It's recommended you pass an updater function in the form of `(prevState) => newState` to update the state, but a direct object can also be passed. + +> 🧠 If `options.onStateChange` is provided, it will be triggered by this function with the new state. + +### `options` + +```tsx +options: TableOptions +``` + +A read-only reference to the table's current options. + +> ⚠️ This property is generally used internally or by adapters. It can be updated by passing new options to your table. This is different per adapter. For adapters themselves, table options must be updated via the `setOptions` function. + +### `setOptions` + +```tsx +setOptions: (newOptions: Updater>) => void +``` + +> ⚠️ This function is generally used by adapters to update the table options. It can be used to update the table options directly, but it is generally not recommended to bypass your adapters strategy for updating table options. + +### `getCoreRowModel` + +```tsx +getCoreRowModel: () => { + rows: Row[], + flatRows: Row[], + rowsById: Record>, +} +``` + +Returns the core row model before any processing has been applied. + +### `getRowModel` + +```tsx +getRowModel: () => { + rows: Row[], + flatRows: Row[], + rowsById: Record>, +} +``` + +Returns the final model after all processing from other used features has been applied. + +### `getAllColumns` + +```tsx +type getAllColumns = () => Column[] +``` + +Returns all columns in the table in their normalized and nested hierarchy, mirrored from the column defs passed to the table. + +### `getAllFlatColumns` + +```tsx +type getAllFlatColumns = () => Column[] +``` + +Returns all columns in the table flattened to a single level. This includes parent column objects throughout the hierarchy. + +### `getAllLeafColumns` + +```tsx +type getAllLeafColumns = () => Column[] +``` + +Returns all leaf-node columns in the table flattened to a single level. This does not include parent columns. + +### `getColumn` + +```tsx +type getColumn = (id: string) => Column | undefined +``` + +Returns a single column by its ID. + +### `getHeaderGroups` + +```tsx +type getHeaderGroups = () => HeaderGroup[] +``` + +Returns the header groups for the table. + +### `getFooterGroups` + +```tsx +type getFooterGroups = () => HeaderGroup[] +``` + +Returns the footer groups for the table. + +### `getFlatHeaders` + +```tsx +type getFlatHeaders = () => Header[] +``` + +Returns a flattened array of Header objects for the table, including parent headers. + +### `getLeafHeaders` + +```tsx +type getLeafHeaders = () => Header[] +``` + +Returns a flattened array of leaf-node Header objects for the table. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-faceting.md b/.cursor/tanstack/table-main/docs/api/features/column-faceting.md new file mode 100644 index 0000000..2a951da --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-faceting.md @@ -0,0 +1,46 @@ +--- +title: Column Faceting APIs +id: column-faceting +--- + +## Column API + +### `getFacetedRowModel` + +```tsx +type getFacetedRowModel = () => RowModel +``` + +> ⚠️ Requires that you pass a valid `getFacetedRowModel` function to `options.facetedRowModel`. A default implementation is provided via the exported `getFacetedRowModel` function. + +Returns the row model with all other column filters applied, excluding its own filter. Useful for displaying faceted result counts. + +### `getFacetedUniqueValues` + +```tsx +getFacetedUniqueValues: () => Map +``` + +> ⚠️ Requires that you pass a valid `getFacetedUniqueValues` function to `options.getFacetedUniqueValues`. A default implementation is provided via the exported `getFacetedUniqueValues` function. + +A function that **computes and returns** a `Map` of unique values and their occurrences derived from `column.getFacetedRowModel`. Useful for displaying faceted result values. + +### `getFacetedMinMaxValues` + +```tsx +getFacetedMinMaxValues: () => Map +``` + +> ⚠️ Requires that you pass a valid `getFacetedMinMaxValues` function to `options.getFacetedMinMaxValues`. A default implementation is provided via the exported `getFacetedMinMaxValues` function. + +A function that **computes and returns** a min/max tuple derived from `column.getFacetedRowModel`. Useful for displaying faceted result values. + +## Table Options + +### `getColumnFacetedRowModel` + +```tsx +getColumnFacetedRowModel: (columnId: string) => RowModel +``` + +Returns the faceted row model for a given columnId. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-filtering.md b/.cursor/tanstack/table-main/docs/api/features/column-filtering.md new file mode 100644 index 0000000..b32d4f3 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-filtering.md @@ -0,0 +1,396 @@ +--- +title: Column Filtering APIs +id: column-filtering +--- + +## Can-Filter + +The ability for a column to be **column** filtered is determined by the following: + +- The column was defined with a valid `accessorKey`/`accessorFn`. +- `column.enableColumnFilter` is not set to `false` +- `options.enableColumnFilters` is not set to `false` +- `options.enableFilters` is not set to `false` + +## State + +Filter state is stored on the table using the following shape: + +```tsx +export interface ColumnFiltersTableState { + columnFilters: ColumnFiltersState +} + +export type ColumnFiltersState = ColumnFilter[] + +export interface ColumnFilter { + id: string + value: unknown +} +``` + +## Filter Functions + +The following filter functions are built-in to the table core: + +- `includesString` + - Case-insensitive string inclusion +- `includesStringSensitive` + - Case-sensitive string inclusion +- `equalsString` + - Case-insensitive string equality +- `equalsStringSensitive` + - Case-sensitive string equality +- `arrIncludes` + - Item inclusion within an array +- `arrIncludesAll` + - All items included in an array +- `arrIncludesSome` + - Some items included in an array +- `equals` + - Object/referential equality `Object.is`/`===` +- `weakEquals` + - Weak object/referential equality `==` +- `inNumberRange` + - Number range inclusion + +Every filter function receives: + +- The row to filter +- The columnId to use to retrieve the row's value +- The filter value + +and should return `true` if the row should be included in the filtered rows, and `false` if it should be removed. + +This is the type signature for every filter function: + +```tsx +export type FilterFn = { + ( + row: Row, + columnId: string, + filterValue: any, + addMeta: (meta: any) => void + ): boolean + resolveFilterValue?: TransformFilterValueFn + autoRemove?: ColumnFilterAutoRemoveTestFn + addMeta?: (meta?: any) => void +} + +export type TransformFilterValueFn = ( + value: any, + column?: Column +) => unknown + +export type ColumnFilterAutoRemoveTestFn = ( + value: any, + column?: Column +) => boolean + +export type CustomFilterFns = Record< + string, + FilterFn +> +``` + +### `filterFn.resolveFilterValue` + +This optional "hanging" method on any given `filterFn` allows the filter function to transform/sanitize/format the filter value before it is passed to the filter function. + +### `filterFn.autoRemove` + +This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. + +#### Using Filter Functions + +Filter functions can be used/referenced/defined by passing the following to `columnDefinition.filterFn`: + +- A `string` that references a built-in filter function +- A function directly provided to the `columnDefinition.filterFn` option + +The final list of filter functions available for the `columnDef.filterFn` option use the following type: + +```tsx +export type FilterFnOption = + | 'auto' + | BuiltInFilterFn + | FilterFn +``` + +#### Filter Meta + +Filtering data can often expose additional information about the data that can be used to aid other future operations on the same data. A good example of this concept is a ranking-system like that of [`match-sorter`](https://github.com/kentcdodds/match-sorter) that simultaneously ranks, filters and sorts data. While utilities like `match-sorter` make a lot of sense for single-dimensional filter+sort tasks, the decoupled filtering/sorting architecture of building a table makes them very difficult and slow to use. + +To make a ranking/filtering/sorting system work with tables, `filterFn`s can optionally mark results with a **filter meta** value that can be used later to sort/group/etc the data to your liking. This is done by calling the `addMeta` function supplied to your custom `filterFn`. + +Below is an example using our own `match-sorter-utils` package (a utility fork of `match-sorter`) to rank, filter, and sort the data + +```tsx +import { sortingFns } from '@tanstack/react-table' + +import { rankItem, compareItems } from '@tanstack/match-sorter-utils' + +const fuzzyFilter = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the ranking info + addMeta(itemRank) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const fuzzySort = (rowA, rowB, columnId) => { + let dir = 0 + + // Only sort by rank if the column has ranking information + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId]!, + rowB.columnFiltersMeta[columnId]! + ) + } + + // Provide an alphanumeric fallback for when the item ranks are equal + return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir +} +``` + +## Column Def Options + +### `filterFn` + +```tsx +filterFn?: FilterFn | keyof FilterFns | keyof BuiltInFilterFns +``` + +The filter function to use with this column. + +Options: + +- A `string` referencing a [built-in filter function](#filter-functions)) +- A [custom filter function](#filter-functions) + +### `enableColumnFilter` + +```tsx +enableColumnFilter?: boolean +``` + +Enables/disables the **column** filter for this column. + +## Column API + +### `getCanFilter` + +```tsx +getCanFilter: () => boolean +``` + +Returns whether or not the column can be **column** filtered. + +### `getFilterIndex` + +```tsx +getFilterIndex: () => number +``` + +Returns the index (including `-1`) of the column filter in the table's `state.columnFilters` array. + +### `getIsFiltered` + +```tsx +getIsFiltered: () => boolean +``` + +Returns whether or not the column is currently filtered. + +### `getFilterValue` + +```tsx +getFilterValue: () => unknown +``` + +Returns the current filter value of the column. + +### `setFilterValue` + +```tsx +setFilterValue: (updater: Updater) => void +``` + +A function that sets the current filter value for the column. You can pass it a value or an updater function for immutability-safe operations on existing values. + +### `getAutoFilterFn` + +```tsx +getAutoFilterFn: (columnId: string) => FilterFn | undefined +``` + +Returns an automatically calculated filter function for the column based off of the columns first known value. + +### `getFilterFn` + +```tsx +getFilterFn: (columnId: string) => FilterFn | undefined +``` + +Returns the filter function (either user-defined or automatic, depending on configuration) for the columnId specified. + +## Row API + +### `columnFilters` + +```tsx +columnFilters: Record +``` + +The column filters map for the row. This object tracks whether a row is passing/failing specific filters by their column ID. + +### `columnFiltersMeta` + +```tsx +columnFiltersMeta: Record +``` + +The column filters meta map for the row. This object tracks any filter meta for a row as optionally provided during the filtering process. + +## Table Options + +### `filterFns` + +```tsx +filterFns?: Record +``` + +This option allows you to define custom filter functions that can be referenced in a column's `filterFn` option by their key. +Example: + +```tsx +declare module '@tanstack/[adapter]-table' { + interface FilterFns { + myCustomFilter: FilterFn + } +} + +const column = columnHelper.data('key', { + filterFn: 'myCustomFilter', +}) + +const table = useReactTable({ + columns: [column], + filterFns: { + myCustomFilter: (rows, columnIds, filterValue) => { + // return the filtered rows + }, + }, +}) +``` + +### `filterFromLeafRows` + +```tsx +filterFromLeafRows?: boolean +``` + +By default, filtering is done from parent rows down (so if a parent row is filtered out, all of its children will be filtered out as well). Setting this option to `true` will cause filtering to be done from leaf rows up (which means parent rows will be included so long as one of their child or grand-child rows is also included). + +### `maxLeafRowFilterDepth` + +```tsx +maxLeafRowFilterDepth?: number +``` + +By default, filtering is done for all rows (max depth of 100), no matter if they are root level parent rows or the child leaf rows of a parent row. Setting this option to `0` will cause filtering to only be applied to the root level parent rows, with all sub-rows remaining unfiltered. Similarly, setting this option to `1` will cause filtering to only be applied to child leaf rows 1 level deep, and so on. + +This is useful for situations where you want a row's entire child hierarchy to be visible regardless of the applied filter. + +### `enableFilters` + +```tsx +enableFilters?: boolean +``` + +Enables/disables all filters for the table. + +### `manualFiltering` + +```tsx +manualFiltering?: boolean +``` + +Disables the `getFilteredRowModel` from being used to filter data. This may be useful if your table needs to dynamically support both client-side and server-side filtering. + +### `onColumnFiltersChange` + +```tsx +onColumnFiltersChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnFilters` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableColumnFilters` + +```tsx +enableColumnFilters?: boolean +``` + +Enables/disables **all** column filters for the table. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel?: ( + table: Table +) => () => RowModel +``` + +If provided, this function is called **once** per table and should return a **new function** which will calculate and return the row model for the table when it's filtered. + +- For server-side filtering, this function is unnecessary and can be ignored since the server should already return the filtered row model. +- For client-side filtering, this function is required. A default implementation is provided via any table adapter's `{ getFilteredRowModel }` export. + +Example: + +```tsx +import { getFilteredRowModel } from '@tanstack/[adapter]-table' + + + getFilteredRowModel: getFilteredRowModel(), +}) +``` + +## Table API + +### `setColumnFilters` + +```tsx +setColumnFilters: (updater: Updater) => void +``` + +Sets or updates the `state.columnFilters` state. + +### `resetColumnFilters` + +```tsx +resetColumnFilters: (defaultState?: boolean) => void +``` + +Resets the **columnFilters** state to `initialState.columnFilters`, or `true` can be passed to force a default blank state reset to `[]`. + +### `getPreFilteredRowModel` + +```tsx +getPreFilteredRowModel: () => RowModel +``` + +Returns the row model for the table before any **column** filtering has been applied. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel: () => RowModel +``` + +Returns the row model for the table after **column** filtering has been applied. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-ordering.md b/.cursor/tanstack/table-main/docs/api/features/column-ordering.md new file mode 100644 index 0000000..37bfb95 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-ordering.md @@ -0,0 +1,70 @@ +--- +title: Column Ordering APIs +id: column-ordering +--- + +## State + +Column ordering state is stored on the table using the following shape: + +```tsx +export type ColumnOrderTableState = { + columnOrder: ColumnOrderState +} + +export type ColumnOrderState = string[] +``` + +## Table Options + +### `onColumnOrderChange` + +```tsx +onColumnOrderChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnOrder` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +## Table API + +### `setColumnOrder` + +```tsx +setColumnOrder: (updater: Updater) => void +``` + +Sets or updates the `state.columnOrder` state. + +### `resetColumnOrder` + +```tsx +resetColumnOrder: (defaultState?: boolean) => void +``` + +Resets the **columnOrder** state to `initialState.columnOrder`, or `true` can be passed to force a default blank state reset to `[]`. + +## Column API + +### `getIndex` + +```tsx +getIndex: (position?: ColumnPinningPosition) => number +``` + +Returns the index of the column in the order of the visible columns. Optionally pass a `position` parameter to get the index of the column in a sub-section of the table. + +### `getIsFirstColumn` + +```tsx +getIsFirstColumn: (position?: ColumnPinningPosition) => boolean +``` + +Returns `true` if the column is the first column in the order of the visible columns. Optionally pass a `position` parameter to check if the column is the first in a sub-section of the table. + +### `getIsLastColumn` + +```tsx +getIsLastColumn: (position?: ColumnPinningPosition) => boolean +``` + +Returns `true` if the column is the last column in the order of the visible columns. Optionally pass a `position` parameter to check if the column is the last in a sub-section of the table. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/column-pinning.md b/.cursor/tanstack/table-main/docs/api/features/column-pinning.md new file mode 100644 index 0000000..a312b33 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-pinning.md @@ -0,0 +1,266 @@ +--- +title: Column Pinning APIs +id: column-pinning +--- + +## Can-Pin + +The ability for a column to be **pinned** is determined by the following: + +- `options.enablePinning` is not set to `false` +- `options.enableColumnPinning` is not set to `false` +- `columnDefinition.enablePinning` is not set to `false` + +## State + +Pinning state is stored on the table using the following shape: + +```tsx +export type ColumnPinningPosition = false | 'left' | 'right' + +export type ColumnPinningState = { + left?: string[] + right?: string[] +} + + +export type ColumnPinningTableState = { + columnPinning: ColumnPinningState +} +``` + +## Table Options + +### `enableColumnPinning` + +```tsx +enableColumnPinning?: boolean +``` + +Enables/disables column pinning for all columns in the table. + +### `onColumnPinningChange` + +```tsx +onColumnPinningChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnPinning` changes. This overrides the default internal state management, so you will also need to supply `state.columnPinning` from your own managed state. + +## Column Def Options + +### `enablePinning` + +```tsx +enablePinning?: boolean +``` + +Enables/disables pinning for the column. + +## Table API + +### `setColumnPinning` + +```tsx +setColumnPinning: (updater: Updater) => void +``` + +Sets or updates the `state.columnPinning` state. + +### `resetColumnPinning` + +```tsx +resetColumnPinning: (defaultState?: boolean) => void +``` + +Resets the **columnPinning** state to `initialState.columnPinning`, or `true` can be passed to force a default blank state reset to `{ left: [], right: [], }`. + +### `getIsSomeColumnsPinned` + +```tsx +getIsSomeColumnsPinned: (position?: ColumnPinningPosition) => boolean +``` + +Returns whether or not any columns are pinned. Optionally specify to only check for pinned columns in either the `left` or `right` position. + +_Note: Does not account for column visibility_ + +### `getLeftHeaderGroups` + +```tsx +getLeftHeaderGroups: () => HeaderGroup[] +``` + +Returns the left pinned header groups for the table. + +### `getCenterHeaderGroups` + +```tsx +getCenterHeaderGroups: () => HeaderGroup[] +``` + +Returns the unpinned/center header groups for the table. + +### `getRightHeaderGroups` + +```tsx +getRightHeaderGroups: () => HeaderGroup[] +``` + +Returns the right pinned header groups for the table. + +### `getLeftFooterGroups` + +```tsx +getLeftFooterGroups: () => HeaderGroup[] +``` + +Returns the left pinned footer groups for the table. + +### `getCenterFooterGroups` + +```tsx +getCenterFooterGroups: () => HeaderGroup[] +``` + +Returns the unpinned/center footer groups for the table. + +### `getRightFooterGroups` + +```tsx +getRightFooterGroups: () => HeaderGroup[] +``` + +Returns the right pinned footer groups for the table. + +### `getLeftFlatHeaders` + +```tsx +getLeftFlatHeaders: () => Header[] +``` + +Returns a flat array of left pinned headers for the table, including parent headers. + +### `getCenterFlatHeaders` + +```tsx +getCenterFlatHeaders: () => Header[] +``` + +Returns a flat array of unpinned/center headers for the table, including parent headers. + +### `getRightFlatHeaders` + +```tsx +getRightFlatHeaders: () => Header[] +``` + +Returns a flat array of right pinned headers for the table, including parent headers. + +### `getLeftLeafHeaders` + +```tsx +getLeftLeafHeaders: () => Header[] +``` + +Returns a flat array of leaf-node left pinned headers for the table. + +### `getCenterLeafHeaders` + +```tsx +getCenterLeafHeaders: () => Header[] +``` + +Returns a flat array of leaf-node unpinned/center headers for the table. + +### `getRightLeafHeaders` + +```tsx +getRightLeafHeaders: () => Header[] +``` + +Returns a flat array of leaf-node right pinned headers for the table. + +### `getLeftLeafColumns` + +```tsx +getLeftLeafColumns: () => Column[] +``` + +Returns all left pinned leaf columns. + +### `getRightLeafColumns` + +```tsx +getRightLeafColumns: () => Column[] +``` + +Returns all right pinned leaf columns. + +### `getCenterLeafColumns` + +```tsx +getCenterLeafColumns: () => Column[] +``` + +Returns all center pinned (unpinned) leaf columns. + +## Column API + +### `getCanPin` + +```tsx +getCanPin: () => boolean +``` + +Returns whether or not the column can be pinned. + +### `getPinnedIndex` + +```tsx +getPinnedIndex: () => number +``` + +Returns the numeric pinned index of the column within a pinned column group. + +### `getIsPinned` + +```tsx +getIsPinned: () => ColumnPinningPosition +``` + +Returns the pinned position of the column. (`'left'`, `'right'` or `false`) + +### `pin` + +```tsx +pin: (position: ColumnPinningPosition) => void +``` + +Pins a column to the `'left'` or `'right'`, or unpins the column to the center if `false` is passed. + +## Row API + +### `getLeftVisibleCells` + +```tsx +getLeftVisibleCells: () => Cell[] +``` + +Returns all left pinned leaf cells in the row. + +### `getRightVisibleCells` + +```tsx +getRightVisibleCells: () => Cell[] +``` + +Returns all right pinned leaf cells in the row. + +### `getCenterVisibleCells` + +```tsx +getCenterVisibleCells: () => Cell[] +``` + +Returns all center pinned (unpinned) leaf cells in the row. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-sizing.md b/.cursor/tanstack/table-main/docs/api/features/column-sizing.md new file mode 100644 index 0000000..0bf7631 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-sizing.md @@ -0,0 +1,253 @@ +--- +title: Column Sizing APIs +id: column-sizing +--- + +## State + +Column sizing state is stored on the table using the following shape: + +```tsx +export type ColumnSizingTableState = { + columnSizing: ColumnSizing + columnSizingInfo: ColumnSizingInfoState +} + +export type ColumnSizing = Record + +export type ColumnSizingInfoState = { + startOffset: null | number + startSize: null | number + deltaOffset: null | number + deltaPercentage: null | number + isResizingColumn: false | string + columnSizingStart: [string, number][] +} +``` + +## Column Def Options + +### `enableResizing` + +```tsx +enableResizing?: boolean +``` + +Enables or disables column resizing for the column. + +### `size` + +```tsx +size?: number +``` + +The desired size for the column + +### `minSize` + +```tsx +minSize?: number +``` + +The minimum allowed size for the column + +### `maxSize` + +```tsx +maxSize?: number +``` + +The maximum allowed size for the column + +## Column API + +### `getSize` + +```tsx +getSize: () => number +``` + +Returns the current size of the column + +### `getStart` + +```tsx +getStart: (position?: ColumnPinningPosition) => number +``` + +Returns the offset measurement along the row-axis (usually the x-axis for standard tables) for the column, measuring the size of all preceding columns. + +Useful for sticky or absolute positioning of columns. (e.g. `left` or `transform`) + +### `getAfter` + +```tsx +getAfter: (position?: ColumnPinningPosition) => number +``` + +Returns the offset measurement along the row-axis (usually the x-axis for standard tables) for the column, measuring the size of all succeeding columns. + +Useful for sticky or absolute positioning of columns. (e.g. `right` or `transform`) + +### `getCanResize` + +```tsx +getCanResize: () => boolean +``` + +Returns `true` if the column can be resized. + +### `getIsResizing` + +```tsx +getIsResizing: () => boolean +``` + +Returns `true` if the column is currently being resized. + +### `resetSize` + +```tsx +resetSize: () => void +``` + +Resets the column size to its initial size. + +## Header API + +### `getSize` + +```tsx +getSize: () => number +``` + +Returns the size for the header, calculated by summing the size of all leaf-columns that belong to it. + +### `getStart` + +```tsx +getStart: (position?: ColumnPinningPosition) => number +``` + +Returns the offset measurement along the row-axis (usually the x-axis for standard tables) for the header. This is effectively a sum of the offset measurements of all preceding headers. + +### `getResizeHandler` + +```tsx +getResizeHandler: () => (event: unknown) => void +``` + +Returns an event handler function that can be used to resize the header. It can be used as an: + +- `onMouseDown` handler +- `onTouchStart` handler + +The dragging and release events are automatically handled for you. + +## Table Options + +### `enableColumnResizing` + +```tsx +enableColumnResizing?: boolean +``` + +Enables/disables column resizing for \*all columns\*\*. + +### `columnResizeMode` + +```tsx +columnResizeMode?: 'onChange' | 'onEnd' +``` + +Determines when the columnSizing state is updated. `onChange` updates the state when the user is dragging the resize handle. `onEnd` updates the state when the user releases the resize handle. + +### `columnResizeDirection` + +```tsx +columnResizeDirection?: 'ltr' | 'rtl' +``` + +Enables or disables right-to-left support for resizing the column. defaults to 'ltr'. + +### `onColumnSizingChange` + +```tsx +onColumnSizingChange?: OnChangeFn +``` + +This optional function will be called when the columnSizing state changes. If you provide this function, you will be responsible for maintaining its state yourself. You can pass this state back to the table via the `state.columnSizing` table option. + +### `onColumnSizingInfoChange` + +```tsx +onColumnSizingInfoChange?: OnChangeFn +``` + +This optional function will be called when the columnSizingInfo state changes. If you provide this function, you will be responsible for maintaining its state yourself. You can pass this state back to the table via the `state.columnSizingInfo` table option. + +## Table API + +### `setColumnSizing` + +```tsx +setColumnSizing: (updater: Updater) => void +``` + +Sets the column sizing state using an updater function or a value. This will trigger the underlying `onColumnSizingChange` function if one is passed to the table options, otherwise the state will be managed automatically by the table. + +### `setColumnSizingInfo` + +```tsx +setColumnSizingInfo: (updater: Updater) => void +``` + +Sets the column sizing info state using an updater function or a value. This will trigger the underlying `onColumnSizingInfoChange` function if one is passed to the table options, otherwise the state will be managed automatically by the table. + +### `resetColumnSizing` + +```tsx +resetColumnSizing: (defaultState?: boolean) => void +``` + +Resets column sizing to its initial state. If `defaultState` is `true`, the default state for the table will be used instead of the initialValue provided to the table. + +### `resetHeaderSizeInfo` + +```tsx +resetHeaderSizeInfo: (defaultState?: boolean) => void +``` + +Resets column sizing info to its initial state. If `defaultState` is `true`, the default state for the table will be used instead of the initialValue provided to the table. + +### `getTotalSize` + +```tsx +getTotalSize: () => number +``` + +Returns the total size of the table by calculating the sum of the sizes of all leaf-columns. + +### `getLeftTotalSize` + +```tsx +getLeftTotalSize: () => number +``` + +If pinning, returns the total size of the left portion of the table by calculating the sum of the sizes of all left leaf-columns. + +### `getCenterTotalSize` + +```tsx +getCenterTotalSize: () => number +``` + +If pinning, returns the total size of the center portion of the table by calculating the sum of the sizes of all unpinned/center leaf-columns. + +### `getRightTotalSize` + +```tsx +getRightTotalSize: () => number +``` + +If pinning, returns the total size of the right portion of the table by calculating the sum of the sizes of all right leaf-columns. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-visibility.md b/.cursor/tanstack/table-main/docs/api/features/column-visibility.md new file mode 100644 index 0000000..e1280e7 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-visibility.md @@ -0,0 +1,178 @@ +--- +title: Column Visibility APIs +id: column-visibility +--- + +## State + +Column visibility state is stored on the table using the following shape: + +```tsx +export type VisibilityState = Record + +export type VisibilityTableState = { + columnVisibility: VisibilityState +} +``` + +## Column Def Options + +### `enableHiding` + +```tsx +enableHiding?: boolean +``` + +Enables/disables hiding the column + +## Column API + +### `getCanHide` + +```tsx +getCanHide: () => boolean +``` + +Returns whether the column can be hidden + +### `getIsVisible` + +```tsx +getIsVisible: () => boolean +``` + +Returns whether the column is visible + +### `toggleVisibility` + +```tsx +toggleVisibility: (value?: boolean) => void +``` + +Toggles the column visibility + +### `getToggleVisibilityHandler` + +```tsx +getToggleVisibilityHandler: () => (event: unknown) => void +``` + +Returns a function that can be used to toggle the column visibility. This function can be used to bind to an event handler to a checkbox. + +## Table Options + +### `onColumnVisibilityChange` + +```tsx +onColumnVisibilityChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnVisibility` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableHiding` + +```tsx +enableHiding?: boolean +``` + +Enables/disables hiding of columns. + +## Table API + +### `getVisibleFlatColumns` + +```tsx +getVisibleFlatColumns: () => Column[] +``` + +Returns a flat array of columns that are visible, including parent columns. + +### `getVisibleLeafColumns` + +```tsx +getVisibleLeafColumns: () => Column[] +``` + +Returns a flat array of leaf-node columns that are visible. + +### `getLeftVisibleLeafColumns` + +```tsx +getLeftVisibleLeafColumns: () => Column[] +``` + +If column pinning, returns a flat array of leaf-node columns that are visible in the left portion of the table. + +### `getRightVisibleLeafColumns` + +```tsx +getRightVisibleLeafColumns: () => Column[] +``` + +If column pinning, returns a flat array of leaf-node columns that are visible in the right portion of the table. + +### `getCenterVisibleLeafColumns` + +```tsx +getCenterVisibleLeafColumns: () => Column[] +``` + +If column pinning, returns a flat array of leaf-node columns that are visible in the unpinned/center portion of the table. + +### `setColumnVisibility` + +```tsx +setColumnVisibility: (updater: Updater) => void +``` + +Updates the column visibility state via an updater function or value + +### `resetColumnVisibility` + +```tsx +resetColumnVisibility: (defaultState?: boolean) => void +``` + +Resets the column visibility state to the initial state. If `defaultState` is provided, the state will be reset to `{}` + +### `toggleAllColumnsVisible` + +```tsx +toggleAllColumnsVisible: (value?: boolean) => void +``` + +Toggles the visibility of all columns + +### `getIsAllColumnsVisible` + +```tsx +getIsAllColumnsVisible: () => boolean +``` + +Returns whether all columns are visible + +### `getIsSomeColumnsVisible` + +```tsx +getIsSomeColumnsVisible: () => boolean +``` + +Returns whether some columns are visible + +### `getToggleAllColumnsVisibilityHandler` + +```tsx +getToggleAllColumnsVisibilityHandler: () => ((event: unknown) => void) +``` + +Returns a handler for toggling the visibility of all columns, meant to be bound to a `input[type=checkbox]` element. + +## Row API + +### `getVisibleCells` + +```tsx +getVisibleCells: () => Cell[] +``` + +Returns an array of cells that account for column visibility for the row. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/expanding.md b/.cursor/tanstack/table-main/docs/api/features/expanding.md new file mode 100644 index 0000000..af7ab0d --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/expanding.md @@ -0,0 +1,208 @@ +--- +title: Expanding APIs +id: expanding +--- + +## State + +Expanding state is stored on the table using the following shape: + +```tsx +export type ExpandedState = true | Record + +export type ExpandedTableState = { + expanded: ExpandedState +} +``` + +## Row API + +### `toggleExpanded` + +```tsx +toggleExpanded: (expanded?: boolean) => void +``` + +Toggles the expanded state (or sets it if `expanded` is provided) for the row. + +### `getIsExpanded` + +```tsx +getIsExpanded: () => boolean +``` + +Returns whether the row is expanded. + +### `getIsAllParentsExpanded` + +```tsx +getIsAllParentsExpanded: () => boolean +``` + +Returns whether all parent rows of the row are expanded. + +### `getCanExpand` + +```tsx +getCanExpand: () => boolean +``` + +Returns whether the row can be expanded. + +### `getToggleExpandedHandler` + +```tsx +getToggleExpandedHandler: () => () => void +``` + +Returns a function that can be used to toggle the expanded state of the row. This function can be used to bind to an event handler to a button. + +## Table Options + +### `manualExpanding` + +```tsx +manualExpanding?: boolean +``` + +Enables manual row expansion. If this is set to `true`, `getExpandedRowModel` will not be used to expand rows and you would be expected to perform the expansion in your own data model. This is useful if you are doing server-side expansion. + +### `onExpandedChange` + +```tsx +onExpandedChange?: OnChangeFn +``` + +This function is called when the `expanded` table state changes. If a function is provided, you will be responsible for managing this state on your own. To pass the managed state back to the table, use the `tableOptions.state.expanded` option. + +### `autoResetExpanded` + +```tsx +autoResetExpanded?: boolean +``` + +Enable this setting to automatically reset the expanded state of the table when expanding state changes. + +### `enableExpanding` + +```tsx +enableExpanding?: boolean +``` + +Enable/disable expanding for all rows. + +### `getExpandedRowModel` + +```tsx +getExpandedRowModel?: (table: Table) => () => RowModel +``` + +This function is responsible for returning the expanded row model. If this function is not provided, the table will not expand rows. You can use the default exported `getExpandedRowModel` function to get the expanded row model or implement your own. + +### `getIsRowExpanded` + +```tsx +getIsRowExpanded?: (row: Row) => boolean +``` + +If provided, allows you to override the default behavior of determining whether a row is currently expanded. + +### `getRowCanExpand` + +```tsx +getRowCanExpand?: (row: Row) => boolean +``` + +If provided, allows you to override the default behavior of determining whether a row can be expanded. + +### `paginateExpandedRows` + +```tsx +paginateExpandedRows?: boolean +``` + +If `true` expanded rows will be paginated along with the rest of the table (which means expanded rows may span multiple pages). + +If `false` expanded rows will not be considered for pagination (which means expanded rows will always render on their parents page. This also means more rows will be rendered than the set page size) + +## Table API + +### `setExpanded` + +```tsx +setExpanded: (updater: Updater) => void +``` + +Updates the expanded state of the table via an update function or value + +### `toggleAllRowsExpanded` + +```tsx +toggleAllRowsExpanded: (expanded?: boolean) => void +``` + +Toggles the expanded state for all rows. Optionally, provide a value to set the expanded state to. + +### `resetExpanded` + +```tsx +resetExpanded: (defaultState?: boolean) => void +``` + +Reset the expanded state of the table to the initial state. If `defaultState` is provided, the expanded state will be reset to `{}` + +### `getCanSomeRowsExpand` + +```tsx +getCanSomeRowsExpand: () => boolean +``` + +Returns whether there are any rows that can be expanded. + +### `getToggleAllRowsExpandedHandler` + +```tsx +getToggleAllRowsExpandedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle the expanded state of all rows. This handler is meant to be used with an `input[type=checkbox]` element. + +### `getIsSomeRowsExpanded` + +```tsx +getIsSomeRowsExpanded: () => boolean +``` + +Returns whether there are any rows that are currently expanded. + +### `getIsAllRowsExpanded` + +```tsx +getIsAllRowsExpanded: () => boolean +``` + +Returns whether all rows are currently expanded. + +### `getExpandedDepth` + +```tsx +getExpandedDepth: () => number +``` + +Returns the maximum depth of the expanded rows. + +### `getExpandedRowModel` + +```tsx +getExpandedRowModel: () => RowModel +``` + +Returns the row model after expansion has been applied. + +### `getPreExpandedRowModel` + +```tsx +getPreExpandedRowModel: () => RowModel +``` + +Returns the row model before expansion has been applied. diff --git a/.cursor/tanstack/table-main/docs/api/features/filters.md b/.cursor/tanstack/table-main/docs/api/features/filters.md new file mode 100644 index 0000000..ea3d718 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/filters.md @@ -0,0 +1,13 @@ +--- +title: Filter APIs +id: filters +--- + + + +The Filtering API docs are now split into multiple API doc pages: + +- [Column Faceting](../api/features/column-faceting) +- [Global Faceting](../api/features/global-faceting) +- [Column Filtering](../api/features/column-filtering) +- [Global Filtering](../api/features/global-filtering) diff --git a/.cursor/tanstack/table-main/docs/api/features/global-faceting.md b/.cursor/tanstack/table-main/docs/api/features/global-faceting.md new file mode 100644 index 0000000..820df88 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/global-faceting.md @@ -0,0 +1,30 @@ +--- +title: Global Faceting APIs +id: global-faceting +--- + +## Table API + +### `getGlobalFacetedRowModel` + +```tsx +getGlobalFacetedRowModel: () => RowModel +``` + +Returns the faceted row model for the global filter. + +### `getGlobalFacetedUniqueValues` + +```tsx +getGlobalFacetedUniqueValues: () => Map +``` + +Returns the faceted unique values for the global filter. + +### `getGlobalFacetedMinMaxValues` + +```tsx +getGlobalFacetedMinMaxValues: () => [number, number] +``` + +Returns the faceted min and max values for the global filter. diff --git a/.cursor/tanstack/table-main/docs/api/features/global-filtering.md b/.cursor/tanstack/table-main/docs/api/features/global-filtering.md new file mode 100644 index 0000000..7f7f27e --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/global-filtering.md @@ -0,0 +1,291 @@ +--- +title: Global Filtering APIs +id: global-filtering +--- + +## Can-Filter + +The ability for a column to be **globally** filtered is determined by the following: + +- The column was defined a valid `accessorKey`/`accessorFn`. +- If provided, `options.getColumnCanGlobalFilter` returns `true` for the given column. If it is not provided, the column is assumed to be globally filterable if the value in the first row is a `string` or `number` type. +- `column.enableColumnFilter` is not set to `false` +- `options.enableColumnFilters` is not set to `false` +- `options.enableFilters` is not set to `false` + +## State + +Filter state is stored on the table using the following shape: + +```tsx +export interface GlobalFilterTableState { + globalFilter: any +} +``` + +## Filter Functions + +You can use the same filter functions that are available for column filtering for global filtering. See the [Column Filtering APIs](../api/features/column-filtering) to learn more about filter functions. + +#### Using Filter Functions + +Filter functions can be used/referenced/defined by passing the following to `options.globalFilterFn`: + +- A `string` that references a built-in filter function +- A function directly provided to the `options.globalFilterFn` option + +The final list of filter functions available for the `tableOptions.globalFilterFn` options use the following type: + +```tsx +export type FilterFnOption = + | 'auto' + | BuiltInFilterFn + | FilterFn +``` + +#### Filter Meta + +Filtering data can often expose additional information about the data that can be used to aid other future operations on the same data. A good example of this concept is a ranking-system like that of [`match-sorter`](https://github.com/kentcdodds/match-sorter) that simultaneously ranks, filters and sorts data. While utilities like `match-sorter` make a lot of sense for single-dimensional filter+sort tasks, the decoupled filtering/sorting architecture of building a table makes them very difficult and slow to use. + +To make a ranking/filtering/sorting system work with tables, `filterFn`s can optionally mark results with a **filter meta** value that can be used later to sort/group/etc the data to your liking. This is done by calling the `addMeta` function supplied to your custom `filterFn`. + +Below is an example using our own `match-sorter-utils` package (a utility fork of `match-sorter`) to rank, filter, and sort the data + +```tsx +import { sortingFns } from '@tanstack/[adapter]-table' + +import { rankItem, compareItems } from '@tanstack/match-sorter-utils' + +const fuzzyFilter = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the ranking info + addMeta(itemRank) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const fuzzySort = (rowA, rowB, columnId) => { + let dir = 0 + + // Only sort by rank if the column has ranking information + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId]!, + rowB.columnFiltersMeta[columnId]! + ) + } + + // Provide an alphanumeric fallback for when the item ranks are equal + return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir +} +``` + +## Column Def Options + +### `enableGlobalFilter` + +```tsx +enableGlobalFilter?: boolean +``` + +Enables/disables the **global** filter for this column. + +## Column API + +### `getCanGlobalFilter` + +```tsx +getCanGlobalFilter: () => boolean +``` + +Returns whether or not the column can be **globally** filtered. Set to `false` to disable a column from being scanned during global filtering. + +## Row API + +### `columnFiltersMeta` + +```tsx +columnFiltersMeta: Record +``` + +The column filters meta map for the row. This object tracks any filter meta for a row as optionally provided during the filtering process. + +## Table Options + +### `filterFns` + +```tsx +filterFns?: Record +``` + +This option allows you to define custom filter functions that can be referenced in a column's `filterFn` option by their key. +Example: + +```tsx +declare module '@tanstack/table-core' { + interface FilterFns { + myCustomFilter: FilterFn + } +} + +const column = columnHelper.data('key', { + filterFn: 'myCustomFilter', +}) + +const table = useReactTable({ + columns: [column], + filterFns: { + myCustomFilter: (rows, columnIds, filterValue) => { + // return the filtered rows + }, + }, +}) +``` + +### `filterFromLeafRows` + +```tsx +filterFromLeafRows?: boolean +``` + +By default, filtering is done from parent rows down (so if a parent row is filtered out, all of its children will be filtered out as well). Setting this option to `true` will cause filtering to be done from leaf rows up (which means parent rows will be included so long as one of their child or grand-child rows is also included). + +### `maxLeafRowFilterDepth` + +```tsx +maxLeafRowFilterDepth?: number +``` + +By default, filtering is done for all rows (max depth of 100), no matter if they are root level parent rows or the child leaf rows of a parent row. Setting this option to `0` will cause filtering to only be applied to the root level parent rows, with all sub-rows remaining unfiltered. Similarly, setting this option to `1` will cause filtering to only be applied to child leaf rows 1 level deep, and so on. + +This is useful for situations where you want a row's entire child hierarchy to be visible regardless of the applied filter. + +### `enableFilters` + +```tsx +enableFilters?: boolean +``` + +Enables/disables all filters for the table. + +### `manualFiltering` + +```tsx +manualFiltering?: boolean +``` + +Disables the `getFilteredRowModel` from being used to filter data. This may be useful if your table needs to dynamically support both client-side and server-side filtering. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel?: ( + table: Table +) => () => RowModel +``` + +If provided, this function is called **once** per table and should return a **new function** which will calculate and return the row model for the table when it's filtered. + +- For server-side filtering, this function is unnecessary and can be ignored since the server should already return the filtered row model. +- For client-side filtering, this function is required. A default implementation is provided via any table adapter's `{ getFilteredRowModel }` export. + +Example: + +```tsx +import { getFilteredRowModel } from '@tanstack/[adapter]-table' + + getFilteredRowModel: getFilteredRowModel(), +}) +``` + +### `globalFilterFn` + +```tsx +globalFilterFn?: FilterFn | keyof FilterFns | keyof BuiltInFilterFns +``` + +The filter function to use for global filtering. + +Options: + +- A `string` referencing a [built-in filter function](#filter-functions)) +- A `string` that references a custom filter functions provided via the `tableOptions.filterFns` option +- A [custom filter function](#filter-functions) + +### `onGlobalFilterChange` + +```tsx +onGlobalFilterChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.globalFilter` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableGlobalFilter` + +```tsx +enableGlobalFilter?: boolean +``` + +Enables/disables the global filter for the table. + +### `getColumnCanGlobalFilter` + +```tsx +getColumnCanGlobalFilter?: (column: Column) => boolean +``` + +If provided, this function will be called with the column and should return `true` or `false` to indicate whether this column should be used for global filtering. +This is useful if the column can contain data that is not `string` or `number` (i.e. `undefined`). + +## Table API + +### `getPreFilteredRowModel` + +```tsx +getPreFilteredRowModel: () => RowModel +``` + +Returns the row model for the table before any **column** filtering has been applied. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel: () => RowModel +``` + +Returns the row model for the table after **column** filtering has been applied. + +### `setGlobalFilter` + +```tsx +setGlobalFilter: (updater: Updater) => void +``` + +Sets or updates the `state.globalFilter` state. + +### `resetGlobalFilter` + +```tsx +resetGlobalFilter: (defaultState?: boolean) => void +``` + +Resets the **globalFilter** state to `initialState.globalFilter`, or `true` can be passed to force a default blank state reset to `undefined`. + +### `getGlobalAutoFilterFn` + +```tsx +getGlobalAutoFilterFn: (columnId: string) => FilterFn | undefined +``` + +Currently, this function returns the built-in `includesString` filter function. In future releases, it may return more dynamic filter functions based on the nature of the data provided. + +### `getGlobalFilterFn` + +```tsx +getGlobalFilterFn: (columnId: string) => FilterFn | undefined +``` + +Returns the global filter function (either user-defined or automatic, depending on configuration) for the table. diff --git a/.cursor/tanstack/table-main/docs/api/features/grouping.md b/.cursor/tanstack/table-main/docs/api/features/grouping.md new file mode 100644 index 0000000..b9c2163 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/grouping.md @@ -0,0 +1,353 @@ +--- +title: Grouping APIs +id: grouping +--- + +## State + +Grouping state is stored on the table using the following shape: + +```tsx +export type GroupingState = string[] + +export type GroupingTableState = { + grouping: GroupingState +} +``` + +## Aggregation Functions + +The following aggregation functions are built-in to the table core: + +- `sum` + - Sums the values of a group of rows +- `min` + - Finds the minimum value of a group of rows +- `max` + - Finds the maximum value of a group of rows +- `extent` + - Finds the minimum and maximum values of a group of rows +- `mean` + - Finds the mean/average value of a group of rows +- `median` + - Finds the median value of a group of rows +- `unique` + - Finds the unique values of a group of rows +- `uniqueCount` + - Finds the number of unique values of a group of rows +- `count` + - Calculates the number of rows in a group + +Every grouping function receives: + +- A function to retrieve the leaf values of the groups rows +- A function to retrieve the immediate-child values of the groups rows + +and should return a value (usually primitive) to build the aggregated row model. + +This is the type signature for every aggregation function: + +```tsx +export type AggregationFn = ( + getLeafRows: () => Row[], + getChildRows: () => Row[] +) => any +``` + +#### Using Aggregation Functions + +Aggregation functions can be used/referenced/defined by passing the following to `columnDefinition.aggregationFn`: + +- A `string` that references a built-in aggregation function +- A `string` that references a custom aggregation functions provided via the `tableOptions.aggregationFns` option +- A function directly provided to the `columnDefinition.aggregationFn` option + +The final list of aggregation functions available for the `columnDef.aggregationFn` use the following type: + +```tsx +export type AggregationFnOption = + | 'auto' + | keyof AggregationFns + | BuiltInAggregationFn + | AggregationFn +``` + +## Column Def Options + +### `aggregationFn` + +```tsx +aggregationFn?: AggregationFn | keyof AggregationFns | keyof BuiltInAggregationFns +``` + +The aggregation function to use with this column. + +Options: + +- A `string` referencing a [built-in aggregation function](#aggregation-functions)) +- A [custom aggregation function](#aggregation-functions) + +### `aggregatedCell` + +```tsx +aggregatedCell?: Renderable< + { + table: Table + row: Row + column: Column + cell: Cell + getValue: () => any + renderValue: () => any + } +> +``` + +The cell to display each row for the column if the cell is an aggregate. If a function is passed, it will be passed a props object with the context of the cell and should return the property type for your adapter (the exact type depends on the adapter being used). + +### `enableGrouping` + +```tsx +enableGrouping?: boolean +``` + +Enables/disables grouping for this column. + +### `getGroupingValue` + +```tsx +getGroupingValue?: (row: TData) => any +``` + +Specify a value to be used for grouping rows on this column. If this option is not specified, the value derived from `accessorKey` / `accessorFn` will be used instead. + +## Column API + +### `aggregationFn` + +```tsx +aggregationFn?: AggregationFnOption +``` + +The resolved aggregation function for the column. + +### `getCanGroup` + +```tsx +getCanGroup: () => boolean +``` + +Returns whether or not the column can be grouped. + +### `getIsGrouped` + +```tsx +getIsGrouped: () => boolean +``` + +Returns whether or not the column is currently grouped. + +### `getGroupedIndex` + +```tsx +getGroupedIndex: () => number +``` + +Returns the index of the column in the grouping state. + +### `toggleGrouping` + +```tsx +toggleGrouping: () => void +``` + +Toggles the grouping state of the column. + +### `getToggleGroupingHandler` + +```tsx +getToggleGroupingHandler: () => () => void +``` + +Returns a function that toggles the grouping state of the column. This is useful for passing to the `onClick` prop of a button. + +### `getAutoAggregationFn` + +```tsx +getAutoAggregationFn: () => AggregationFn | undefined +``` + +Returns the automatically inferred aggregation function for the column. + +### `getAggregationFn` + +```tsx +getAggregationFn: () => AggregationFn | undefined +``` + +Returns the aggregation function for the column. + +## Row API + +### `groupingColumnId` + +```tsx +groupingColumnId?: string +``` + +If this row is grouped, this is the id of the column that this row is grouped by. + +### `groupingValue` + +```tsx +groupingValue?: any +``` + +If this row is grouped, this is the unique/shared value for the `groupingColumnId` for all of the rows in this group. + +### `getIsGrouped` + +```tsx +getIsGrouped: () => boolean +``` + +Returns whether or not the row is currently grouped. + +### `getGroupingValue` + +```tsx +getGroupingValue: (columnId: string) => unknown +``` + +Returns the grouping value for any row and column (including leaf rows). + +## Table Options + +### `aggregationFns` + +```tsx +aggregationFns?: Record +``` + +This option allows you to define custom aggregation functions that can be referenced in a column's `aggregationFn` option by their key. +Example: + +```tsx +declare module '@tanstack/table-core' { + interface AggregationFns { + myCustomAggregation: AggregationFn + } +} + +const column = columnHelper.data('key', { + aggregationFn: 'myCustomAggregation', +}) + +const table = useReactTable({ + columns: [column], + aggregationFns: { + myCustomAggregation: (columnId, leafRows, childRows) => { + // return the aggregated value + }, + }, +}) +``` + +### `manualGrouping` + +```tsx +manualGrouping?: boolean +``` + +Enables manual grouping. If this option is set to `true`, the table will not automatically group rows using `getGroupedRowModel()` and instead will expect you to manually group the rows before passing them to the table. This is useful if you are doing server-side grouping and aggregation. + +### `onGroupingChange` + +```tsx +onGroupingChange?: OnChangeFn +``` + +If this function is provided, it will be called when the grouping state changes and you will be expected to manage the state yourself. You can pass the managed state back to the table via the `tableOptions.state.grouping` option. + +### `enableGrouping` + +```tsx +enableGrouping?: boolean +``` + +Enables/disables grouping for all columns. + +### `getGroupedRowModel` + +```tsx +getGroupedRowModel?: (table: Table) => () => RowModel +``` + +Returns the row model after grouping has taken place, but no further. + +### `groupedColumnMode` + +```tsx +groupedColumnMode?: false | 'reorder' | 'remove' // default: `reorder` +``` + +Grouping columns are automatically reordered by default to the start of the columns list. If you would rather remove them or leave them as-is, set the appropriate mode here. + +## Table API + +### `setGrouping` + +```tsx +setGrouping: (updater: Updater) => void +``` + +Sets or updates the `state.grouping` state. + +### `resetGrouping` + +```tsx +resetGrouping: (defaultState?: boolean) => void +``` + +Resets the **grouping** state to `initialState.grouping`, or `true` can be passed to force a default blank state reset to `[]`. + +### `getPreGroupedRowModel` + +```tsx +getPreGroupedRowModel: () => RowModel +``` + +Returns the row model for the table before any grouping has been applied. + +### `getGroupedRowModel` + +```tsx +getGroupedRowModel: () => RowModel +``` + +Returns the row model for the table after grouping has been applied. + +## Cell API + +### `getIsAggregated` + +```tsx +getIsAggregated: () => boolean +``` + +Returns whether or not the cell is currently aggregated. + +### `getIsGrouped` + +```tsx +getIsGrouped: () => boolean +``` + +Returns whether or not the cell is currently grouped. + +### `getIsPlaceholder` + +```tsx +getIsPlaceholder: () => boolean +``` + +Returns whether or not the cell is currently a placeholder. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/pagination.md b/.cursor/tanstack/table-main/docs/api/features/pagination.md new file mode 100644 index 0000000..5e80d5a --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/pagination.md @@ -0,0 +1,207 @@ +--- +title: Pagination APIs +id: pagination +--- + +## State + +Pagination state is stored on the table using the following shape: + +```tsx +export type PaginationState = { + pageIndex: number + pageSize: number +} + +export type PaginationTableState = { + pagination: PaginationState +} + +export type PaginationInitialTableState = { + pagination?: Partial +} +``` + +## Table Options + +### `manualPagination` + +```tsx +manualPagination?: boolean +``` + +Enables manual pagination. If this option is set to `true`, the table will not automatically paginate rows using `getPaginationRowModel()` and instead will expect you to manually paginate the rows before passing them to the table. This is useful if you are doing server-side pagination and aggregation. + +### `pageCount` + +```tsx +pageCount?: number +``` + +When manually controlling pagination, you can supply a total `pageCount` value to the table if you know it. If you do not know how many pages there are, you can set this to `-1`. Alternatively, you can provide a `rowCount` value and the table will calculate the `pageCount` internally. + +### `rowCount` + +```tsx +rowCount?: number +``` + +When manually controlling pagination, you can supply a total `rowCount` value to the table if you know it. `pageCount` will be calculated internally from `rowCount` and `pageSize`. + +### `autoResetPageIndex` + +```tsx +autoResetPageIndex?: boolean +``` + +If set to `true`, pagination will be reset to the first page when page-altering state changes eg. `data` is updated, filters change, grouping changes, etc. + +> 🧠 Note: This option defaults to `false` if `manualPagination` is set to `true` + +### `onPaginationChange` + +```tsx +onPaginationChange?: OnChangeFn +``` + +If this function is provided, it will be called when the pagination state changes and you will be expected to manage the state yourself. You can pass the managed state back to the table via the `tableOptions.state.pagination` option. + +### `getPaginationRowModel` + +```tsx +getPaginationRowModel?: (table: Table) => () => RowModel +``` + +Returns the row model after pagination has taken place, but no further. + +Pagination columns are automatically reordered by default to the start of the columns list. If you would rather remove them or leave them as-is, set the appropriate mode here. + +## Table API + +### `setPagination` + +```tsx +setPagination: (updater: Updater) => void +``` + +Sets or updates the `state.pagination` state. + +### `resetPagination` + +```tsx +resetPagination: (defaultState?: boolean) => void +``` + +Resets the **pagination** state to `initialState.pagination`, or `true` can be passed to force a default blank state reset to `[]`. + +### `setPageIndex` + +```tsx +setPageIndex: (updater: Updater) => void +``` + +Updates the page index using the provided function or value. + +### `resetPageIndex` + +```tsx +resetPageIndex: (defaultState?: boolean) => void +``` + +Resets the page index to its initial state. If `defaultState` is `true`, the page index will be reset to `0` regardless of initial state. + +### `setPageSize` + +```tsx +setPageSize: (updater: Updater) => void +``` + +Updates the page size using the provided function or value. + +### `resetPageSize` + +```tsx +resetPageSize: (defaultState?: boolean) => void +``` + +Resets the page size to its initial state. If `defaultState` is `true`, the page size will be reset to `10` regardless of initial state. + +### `getPageOptions` + +```tsx +getPageOptions: () => number[] +``` + +Returns an array of page options (zero-index-based) for the current page size. + +### `getCanPreviousPage` + +```tsx +getCanPreviousPage: () => boolean +``` + +Returns whether the table can go to the previous page. + +### `getCanNextPage` + +```tsx +getCanNextPage: () => boolean +``` + +Returns whether the table can go to the next page. + +### `previousPage` + +```tsx +previousPage: () => void +``` + +Decrements the page index by one, if possible. + +### `nextPage` + +```tsx +nextPage: () => void +``` + +Increments the page index by one, if possible. + +### `firstPage` + +```tsx +firstPage: () => void +``` + +Sets the page index to `0`. + +### `lastPage` + +```tsx +lastPage: () => void +``` + +Sets the page index to the last available page. + +### `getPageCount` + +```tsx +getPageCount: () => number +``` + +Returns the page count. If manually paginating or controlling the pagination state, this will come directly from the `options.pageCount` table option, otherwise it will be calculated from the table data using the total row count and current page size. + +### `getPrePaginationRowModel` + +```tsx +getPrePaginationRowModel: () => RowModel +``` + +Returns the row model for the table before any pagination has been applied. + +### `getPaginationRowModel` + +```tsx +getPaginationRowModel: () => RowModel +``` + +Returns the row model for the table after pagination has been applied. diff --git a/.cursor/tanstack/table-main/docs/api/features/pinning.md b/.cursor/tanstack/table-main/docs/api/features/pinning.md new file mode 100644 index 0000000..dd9f58c --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/pinning.md @@ -0,0 +1,11 @@ +--- +title: Pinning APIs +id: pinning +--- + + + +The pinning apis are now split into multiple api pages: + +- [Column Pinning](../api/features/column-pinning) +- [Row Pinning](../api/features/row-pinning) diff --git a/.cursor/tanstack/table-main/docs/api/features/row-pinning.md b/.cursor/tanstack/table-main/docs/api/features/row-pinning.md new file mode 100644 index 0000000..52e46d5 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/row-pinning.md @@ -0,0 +1,138 @@ +--- +title: Row Pinning APIs +id: row-pinning +--- + +## Can-Pin + +The ability for a row to be **pinned** is determined by the following: + +- `options.enableRowPinning` resolves to `true` +- `options.enablePinning` is not set to `false` + +## State + +Pinning state is stored on the table using the following shape: + +```tsx +export type RowPinningPosition = false | 'top' | 'bottom' + +export type RowPinningState = { + top?: string[] + bottom?: string[] +} + +export type RowPinningRowState = { + rowPinning: RowPinningState +} +``` + +## Table Options + +### `enableRowPinning` + +```tsx +enableRowPinning?: boolean | ((row: Row) => boolean) +``` + +Enables/disables row pinning for all rows in the table. + +### `keepPinnedRows` + +```tsx +keepPinnedRows?: boolean +``` + +When `false`, pinned rows will not be visible if they are filtered or paginated out of the table. When `true`, pinned rows will always be visible regardless of filtering or pagination. Defaults to `true`. + +### `onRowPinningChange` + +```tsx +onRowPinningChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.rowPinning` changes. This overrides the default internal state management, so you will also need to supply `state.rowPinning` from your own managed state. + +## Table API + +### `setRowPinning` + +```tsx +setRowPinning: (updater: Updater) => void +``` + +Sets or updates the `state.rowPinning` state. + +### `resetRowPinning` + +```tsx +resetRowPinning: (defaultState?: boolean) => void +``` + +Resets the **rowPinning** state to `initialState.rowPinning`, or `true` can be passed to force a default blank state reset to `{}`. + +### `getIsSomeRowsPinned` + +```tsx +getIsSomeRowsPinned: (position?: RowPinningPosition) => boolean +``` + +Returns whether or not any rows are pinned. Optionally specify to only check for pinned rows in either the `top` or `bottom` position. + +### `getTopRows` + +```tsx +getTopRows: () => Row[] +``` + +Returns all top pinned rows. + +### `getBottomRows` + +```tsx +getBottomRows: () => Row[] +``` + +Returns all bottom pinned rows. + +### `getCenterRows` + +```tsx +getCenterRows: () => Row[] +``` + +Returns all rows that are not pinned to the top or bottom. + +## Row API + +### `pin` + +```tsx +pin: (position: RowPinningPosition) => void +``` + +Pins a row to the `'top'` or `'bottom'`, or unpins the row to the center if `false` is passed. + +### `getCanPin` + +```tsx +getCanPin: () => boolean +``` + +Returns whether or not the row can be pinned. + +### `getIsPinned` + +```tsx +getIsPinned: () => RowPinningPosition +``` + +Returns the pinned position of the row. (`'top'`, `'bottom'` or `false`) + +### `getPinnedIndex` + +```tsx +getPinnedIndex: () => number +``` + +Returns the numeric pinned index of the row within a pinned row group. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/row-selection.md b/.cursor/tanstack/table-main/docs/api/features/row-selection.md new file mode 100644 index 0000000..abbbb2a --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/row-selection.md @@ -0,0 +1,230 @@ +--- +title: Row Selection APIs +id: row-selection +--- + +## State + +Row selection state is stored on the table using the following shape: + +```tsx +export type RowSelectionState = Record + +export type RowSelectionTableState = { + rowSelection: RowSelectionState +} +``` + +By default, the row selection state uses the index of each row as the row identifiers. Row selection state can instead be tracked with a custom unique row id by passing in a custom [getRowId](../core/table.md#getrowid) function to the the table. + +## Table Options + +### `enableRowSelection` + +```tsx +enableRowSelection?: boolean | ((row: Row) => boolean) +``` + +- Enables/disables row selection for all rows in the table OR +- A function that given a row, returns whether to enable/disable row selection for that row + +### `enableMultiRowSelection` + +```tsx +enableMultiRowSelection?: boolean | ((row: Row) => boolean) +``` + +- Enables/disables multiple row selection for all rows in the table OR +- A function that given a row, returns whether to enable/disable multiple row selection for that row's children/grandchildren + +### `enableSubRowSelection` + +```tsx +enableSubRowSelection?: boolean | ((row: Row) => boolean) +``` + +Enables/disables automatic sub-row selection when a parent row is selected, or a function that enables/disables automatic sub-row selection for each row. + +(Use in combination with expanding or grouping features) + +### `onRowSelectionChange` + +```tsx +onRowSelectionChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.rowSelection` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +## Table API + +### `getToggleAllRowsSelectedHandler` + +```tsx +getToggleAllRowsSelectedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle all rows in the table. + +### `getToggleAllPageRowsSelectedHandler` + +```tsx +getToggleAllPageRowsSelectedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle all rows on the current page. + +### `setRowSelection` + +```tsx +setRowSelection: (updater: Updater) => void +``` + +Sets or updates the `state.rowSelection` state. + +### `resetRowSelection` + +```tsx +resetRowSelection: (defaultState?: boolean) => void +``` + +Resets the **rowSelection** state to the `initialState.rowSelection`, or `true` can be passed to force a default blank state reset to `{}`. + +### `getIsAllRowsSelected` + +```tsx +getIsAllRowsSelected: () => boolean +``` + +Returns whether or not all rows in the table are selected. + +### `getIsAllPageRowsSelected` + +```tsx +getIsAllPageRowsSelected: () => boolean +``` + +Returns whether or not all rows on the current page are selected. + +### `getIsSomeRowsSelected` + +```tsx +getIsSomeRowsSelected: () => boolean +``` + +Returns whether or not any rows in the table are selected. + +NOTE: Returns `false` if all rows are selected. + +### `getIsSomePageRowsSelected` + +```tsx +getIsSomePageRowsSelected: () => boolean +``` + +Returns whether or not any rows on the current page are selected. + +### `toggleAllRowsSelected` + +```tsx +toggleAllRowsSelected: (value: boolean) => void +``` + +Selects/deselects all rows in the table. + +### `toggleAllPageRowsSelected` + +```tsx +toggleAllPageRowsSelected: (value: boolean) => void +``` + +Selects/deselects all rows on the current page. + +### `getPreSelectedRowModel` + +```tsx +getPreSelectedRowModel: () => RowModel +``` + +### `getSelectedRowModel` + +```tsx +getSelectedRowModel: () => RowModel +``` + +### `getFilteredSelectedRowModel` + +```tsx +getFilteredSelectedRowModel: () => RowModel +``` + +### `getGroupedSelectedRowModel` + +```tsx +getGroupedSelectedRowModel: () => RowModel +``` + +## Row API + +### `getIsSelected` + +```tsx +getIsSelected: () => boolean +``` + +Returns whether or not the row is selected. + +### `getIsSomeSelected` + +```tsx +getIsSomeSelected: () => boolean +``` + +Returns whether or not some of the row's sub rows are selected. + +### `getIsAllSubRowsSelected` + +```tsx +getIsAllSubRowsSelected: () => boolean +``` + +Returns whether or not all of the row's sub rows are selected. + +### `getCanSelect` + +```tsx +getCanSelect: () => boolean +``` + +Returns whether or not the row can be selected. + +### `getCanMultiSelect` + +```tsx +getCanMultiSelect: () => boolean +``` + +Returns whether or not the row can multi-select. + +### `getCanSelectSubRows` + +```tsx +getCanSelectSubRows: () => boolean +``` + +Returns whether or not the row can select sub rows automatically when the parent row is selected. + +### `toggleSelected` + +```tsx +toggleSelected: (value?: boolean) => void +``` + +Selects/deselects the row. + +### `getToggleSelectedHandler` + +```tsx +getToggleSelectedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle the row. diff --git a/.cursor/tanstack/table-main/docs/api/features/sorting.md b/.cursor/tanstack/table-main/docs/api/features/sorting.md new file mode 100644 index 0000000..e5b60ee --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/sorting.md @@ -0,0 +1,385 @@ +--- +title: Sorting APIs +id: sorting +--- + +## State + +Sorting state is stored on the table using the following shape: + +```tsx +export type SortDirection = 'asc' | 'desc' + +export type ColumnSort = { + id: string + desc: boolean +} + +export type SortingState = ColumnSort[] + +export type SortingTableState = { + sorting: SortingState +} +``` + +## Sorting Functions + +The following sorting functions are built-in to the table core: + +- `alphanumeric` + - Sorts by mixed alphanumeric values without case-sensitivity. Slower, but more accurate if your strings contain numbers that need to be naturally sorted. +- `alphanumericCaseSensitive` + - Sorts by mixed alphanumeric values with case-sensitivity. Slower, but more accurate if your strings contain numbers that need to be naturally sorted. +- `text` + - Sorts by text/string values without case-sensitivity. Faster, but less accurate if your strings contain numbers that need to be naturally sorted. +- `textCaseSensitive` + - Sorts by text/string values with case-sensitivity. Faster, but less accurate if your strings contain numbers that need to be naturally sorted. +- `datetime` + - Sorts by time, use this if your values are `Date` objects. +- `basic` + - Sorts using a basic/standard `a > b ? 1 : a < b ? -1 : 0` comparison. This is the fastest sorting function, but may not be the most accurate. + +Every sorting function receives 2 rows and a column ID and are expected to compare the two rows using the column ID to return `-1`, `0`, or `1` in ascending order. Here's a cheat sheet: + +| Return | Ascending Order | +| ------ | --------------- | +| `-1` | `a < b` | +| `0` | `a === b` | +| `1` | `a > b` | + +This is the type signature for every sorting function: + +```tsx +export type SortingFn = { + (rowA: Row, rowB: Row, columnId: string): number +} +``` + +#### Using Sorting Functions + +Sorting functions can be used/referenced/defined by passing the following to `columnDefinition.sortingFn`: + +- A `string` that references a built-in sorting function +- A `string` that references a custom sorting functions provided via the `tableOptions.sortingFns` option +- A function directly provided to the `columnDefinition.sortingFn` option + +The final list of sorting functions available for the `columnDef.sortingFn` use the following type: + +```tsx +export type SortingFnOption = + | 'auto' + | SortingFns + | BuiltInSortingFns + | SortingFn +``` + +## Column Def Options + +### `sortingFn` + +```tsx +sortingFn?: SortingFn | keyof SortingFns | keyof BuiltInSortingFns +``` + +The sorting function to use with this column. + +Options: + +- A `string` referencing a [built-in sorting function](#sorting-functions)) +- A [custom sorting function](#sorting-functions) + +### `sortDescFirst` + +```tsx +sortDescFirst?: boolean +``` + +Set to `true` for sorting toggles on this column to start in the descending direction. + +### `enableSorting` + +```tsx +enableSorting?: boolean +``` + +Enables/Disables sorting for this column. + +### `enableMultiSort` + +```tsx +enableMultiSort?: boolean +``` + +Enables/Disables multi-sorting for this column. + +### `invertSorting` + +```tsx +invertSorting?: boolean +``` + +Inverts the order of the sorting for this column. This is useful for values that have an inverted best/worst scale where lower numbers are better, eg. a ranking (1st, 2nd, 3rd) or golf-like scoring + +### `sortUndefined` + +```tsx +sortUndefined?: 'first' | 'last' | false | -1 | 1 // defaults to 1 +``` + +- `'first'` + - Undefined values will be pushed to the beginning of the list +- `'last'` + - Undefined values will be pushed to the end of the list +- `false` + - Undefined values will be considered tied and need to be sorted by the next column filter or original index (whichever applies) +- `-1` + - Undefined values will be sorted with higher priority (ascending) (if ascending, undefined will appear on the beginning of the list) +- `1` + - Undefined values will be sorted with lower priority (descending) (if ascending, undefined will appear on the end of the list) + +> NOTE: `'first'` and `'last'` options are new in v8.16.0 + +## Column API + +### `getAutoSortingFn` + +```tsx +getAutoSortingFn: () => SortingFn +``` + +Returns a sorting function automatically inferred based on the columns values. + +### `getAutoSortDir` + +```tsx +getAutoSortDir: () => SortDirection +``` + +Returns a sort direction automatically inferred based on the columns values. + +### `getSortingFn` + +```tsx +getSortingFn: () => SortingFn +``` + +Returns the resolved sorting function to be used for this column + +### `getNextSortingOrder` + +```tsx +getNextSortingOrder: () => SortDirection | false +``` + +Returns the next sorting order. + +### `getCanSort` + +```tsx +getCanSort: () => boolean +``` + +Returns whether this column can be sorted. + +### `getCanMultiSort` + +```tsx +getCanMultiSort: () => boolean +``` + +Returns whether this column can be multi-sorted. + +### `getSortIndex` + +```tsx +getSortIndex: () => number +``` + +Returns the index position of this column's sorting within the sorting state + +### `getIsSorted` + +```tsx +getIsSorted: () => false | SortDirection +``` + +Returns whether this column is sorted. + +### `getFirstSortDir` + +```tsx +getFirstSortDir: () => SortDirection +``` + +Returns the first direction that should be used when sorting this column. + +### `clearSorting` + +```tsx +clearSorting: () => void +``` + +Removes this column from the table's sorting state + +### `toggleSorting` + +```tsx +toggleSorting: (desc?: boolean, isMulti?: boolean) => void +``` + +Toggles this columns sorting state. If `desc` is provided, it will force the sort direction to that value. If `isMulti` is provided, it will additivity multi-sort the column (or toggle it if it is already sorted). + +### `getToggleSortingHandler` + +```tsx +getToggleSortingHandler: () => undefined | ((event: unknown) => void) +``` + +Returns a function that can be used to toggle this column's sorting state. This is useful for attaching a click handler to the column header. + +## Table Options + +### `sortingFns` + +```tsx +sortingFns?: Record +``` + +This option allows you to define custom sorting functions that can be referenced in a column's `sortingFn` option by their key. +Example: + +```tsx +declare module '@tanstack/table-core' { + interface SortingFns { + myCustomSorting: SortingFn + } +} + +const column = columnHelper.data('key', { + sortingFn: 'myCustomSorting', +}) + +const table = useReactTable({ + columns: [column], + sortingFns: { + myCustomSorting: (rowA: any, rowB: any, columnId: any): number => + rowA.getValue(columnId).value < rowB.getValue(columnId).value ? 1 : -1, + }, +}) +``` + +### `manualSorting` + +```tsx +manualSorting?: boolean +``` + +Enables manual sorting for the table. If this is `true`, you will be expected to sort your data before it is passed to the table. This is useful if you are doing server-side sorting. + +### `onSortingChange` + +```tsx +onSortingChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.sorting` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableSorting` + +```tsx +enableSorting?: boolean +``` + +Enables/Disables sorting for the table. + +### `enableSortingRemoval` + +```tsx +enableSortingRemoval?: boolean +``` + +Enables/Disables the ability to remove sorting for the table. +- If `true` then changing sort order will circle like: 'none' -> 'desc' -> 'asc' -> 'none' -> ... +- If `false` then changing sort order will circle like: 'none' -> 'desc' -> 'asc' -> 'desc' -> 'asc' -> ... + +### `enableMultiRemove` + +```tsx +enableMultiRemove?: boolean +``` + +Enables/disables the ability to remove multi-sorts + +### `enableMultiSort` + +```tsx +enableMultiSort?: boolean +``` + +Enables/Disables multi-sorting for the table. + +### `sortDescFirst` + +```tsx +sortDescFirst?: boolean +``` + +If `true`, all sorts will default to descending as their first toggle state. + +### `getSortedRowModel` + +```tsx +getSortedRowModel?: (table: Table) => () => RowModel +``` + +This function is used to retrieve the sorted row model. If using server-side sorting, this function is not required. To use client-side sorting, pass the exported `getSortedRowModel()` from your adapter to your table or implement your own. + +### `maxMultiSortColCount` + +```tsx +maxMultiSortColCount?: number +``` + +Set a maximum number of columns that can be multi-sorted. + +### `isMultiSortEvent` + +```tsx +isMultiSortEvent?: (e: unknown) => boolean +``` + +Pass a custom function that will be used to determine if a multi-sort event should be triggered. It is passed the event from the sort toggle handler and should return `true` if the event should trigger a multi-sort. + +## Table API + +### `setSorting` + +```tsx +setSorting: (updater: Updater) => void +``` + +Sets or updates the `state.sorting` state. + +### `resetSorting` + +```tsx +resetSorting: (defaultState?: boolean) => void +``` + +Resets the **sorting** state to `initialState.sorting`, or `true` can be passed to force a default blank state reset to `[]`. + +### `getPreSortedRowModel` + +```tsx +getPreSortedRowModel: () => RowModel +``` + +Returns the row model for the table before any sorting has been applied. + +### `getSortedRowModel` + +```tsx +getSortedRowModel: () => RowModel +``` + +Returns the row model for the table after sorting has been applied. From 341a9192ca49dddc421143fc35279e1087e976b3 Mon Sep 17 00:00:00 2001 From: CinquinAndy Date: Sat, 5 Apr 2025 02:15:17 +0200 Subject: [PATCH 63/73] feat(dependencies): update and add new packages - Upgrade @clerk/nextjs to version 6.13.0 - Add several @radix-ui components including alert-dialog, checkbox, dropdown-menu, label, popover, and select - Update existing @radix-ui/react-slot dependency to use caret (^) for versioning - Introduce new dependencies like @tanstack/react-table and others for enhanced functionality - Update various other package versions for better compatibility and performance --- bun.lock | 37 +- package-lock.json | 926 ++++++++++++++---- package.json | 10 +- .../[[...onboarding]]/OrganizationStep.tsx | 8 +- src/app/(application)/app/projects/page.tsx | 115 +++ src/app/actions/equipment/manageEquipments.ts | 41 +- .../services/pocketbase/projectService.ts | 258 +++-- .../pocketbase/secured/equipment_service.ts | 377 ++++--- .../services/pocketbase/secured/index.ts | 3 +- .../pocketbase/secured/security_middleware.ts | 47 +- .../pocketbase/secured/security_types.ts | 32 + .../webhook/clerk/admin/reconcile/route.ts | 2 +- .../app/projects/projects-table-columns.tsx | 173 ++++ .../app/projects/projects-table.tsx | 259 +++++ src/components/comp-485.tsx | 780 +++++++++++++++ src/components/magicui/confetti.tsx | 5 +- src/components/ui/alert-dialog.tsx | 157 +++ src/components/ui/badge.tsx | 46 + src/components/ui/checkbox.tsx | 59 ++ src/components/ui/dropdown-menu.tsx | 309 ++++++ src/components/ui/label.tsx | 24 + src/components/ui/pagination.tsx | 128 +++ src/components/ui/popover.tsx | 56 ++ src/components/ui/select.tsx | 186 ++++ src/components/ui/stepper.tsx | 1 - src/components/ui/table.tsx | 105 ++ src/hooks/useProjectsTable.ts | 50 + 27 files changed, 3653 insertions(+), 541 deletions(-) create mode 100644 src/app/(application)/app/projects/page.tsx create mode 100644 src/app/actions/services/pocketbase/secured/security_types.ts create mode 100644 src/components/app/projects/projects-table-columns.tsx create mode 100644 src/components/app/projects/projects-table.tsx create mode 100644 src/components/comp-485.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/hooks/useProjectsTable.ts diff --git a/bun.lock b/bun.lock index 1fa16ba..405f8bb 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,19 @@ "@eslint/js": "9.23.0", "@headlessui/react": "2.2.0", "@heroicons/react": "2.2.0", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "1.1.2", - "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@tanstack/react-table": "^8.21.2", "@types/canvas-confetti": "1.9.0", "autoprefixer": "10.4.21", "canvas-confetti": "1.9.3", @@ -184,20 +191,32 @@ "@pkgr/core": ["@pkgr/core@0.2.0", "", {}, "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ=="], + "@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dialog": "1.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.3", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="], @@ -206,6 +225,12 @@ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], @@ -214,6 +239,10 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.1.6", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], @@ -228,6 +257,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="], @@ -288,8 +319,12 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.2", "@tailwindcss/oxide": "4.1.2", "postcss": "^8.4.41", "tailwindcss": "4.1.2" } }, "sha512-vgkMo6QRhG6uv97im6Y4ExDdq71y9v2IGZc+0wn7lauQFYJM/1KdUVhrOkexbUso8tUsMOWALxyHVkQEbsM7gw=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.2", "", { "dependencies": { "@tanstack/table-core": "8.21.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.4", "", { "dependencies": { "@tanstack/virtual-core": "3.13.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jPWC3BXvVLHsMX67NEHpJaZ+/FySoNxFfBEiF4GBc1+/nVwdRm+UcSCYnKP3pXQr0eEsDpXi/PQZhNfJNopH0g=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.2", "", {}, "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.4", "", {}, "sha512-fNGO9fjjSLns87tlcto106enQQLycCKR4DPNpgq3djP5IdcPFdPAmaKjsgzIeRhH7hWrELgW12hYnRthS5kLUw=="], "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], diff --git a/package-lock.json b/package-lock.json index 218a9ea..63a2de7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,32 +8,42 @@ "name": "for-tooling", "version": "0.1.0", "dependencies": { - "@clerk/nextjs": "6.12.12", + "@clerk/nextjs": "6.13.0", "@eslint/js": "9.23.0", "@headlessui/react": "2.2.0", "@heroicons/react": "2.2.0", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "1.1.2", - "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@tanstack/react-table": "^8.21.2", "@types/canvas-confetti": "1.9.0", "autoprefixer": "10.4.21", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "crypto": "1.0.1", + "date-fns": "^4.1.0", "dayjs": "1.11.13", - "framer-motion": "12.6.2", + "framer-motion": "12.6.3", "heroicons": "2.2.0", - "lucide-react": "0.485.0", + "lucide-react": "0.487.0", "next": "15.2.4", "pocketbase": "0.25.2", "postcss": "8.5.3", "react": "19.1.0", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "tailwind-merge": "3.0.2", + "svix": "1.63.1", + "tailwind-merge": "3.1.0", "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3" @@ -41,25 +51,25 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@next/eslint-plugin-next": "15.2.4", - "@tailwindcss/postcss": "4.0.17", - "@types/node": "22.13.14", - "@types/react": "19.0.12", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", + "@tailwindcss/postcss": "4.1.2", + "@types/node": "22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.1", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", "eslint": "9.23.0", "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.1", - "eslint-plugin-prettier": "5.2.5", - "eslint-plugin-react": "7.37.4", + "eslint-plugin-perfectionist": "4.11.0", + "eslint-plugin-prettier": "5.2.6", + "eslint-plugin-react": "7.37.5", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "4.0.17", + "tailwindcss": "4.1.2", "tailwindcss-animate": "1.0.7", "typescript": "5.8.2", - "typescript-eslint": "8.28.0" + "typescript-eslint": "8.29.0" } }, "node_modules/@alloc/quick-lru": { @@ -76,29 +86,37 @@ } }, "node_modules/@clerk/backend": { - "version": "1.25.8", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.25.8.tgz", - "integrity": "sha512-DmIc5pNQeTLHLCLN8ajcNhYNCfqmvwSwyGqr5aCHiJdWqGb9DGaws7PXU9btBiXVbI+NK/CJwjGv09+2rGpgAg==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.26.0.tgz", + "integrity": "sha512-ioZBMnwm4DD8IVPGDjFW3wkyn1JTMvTlsmdHGYsjdbXLtbRFVRJelAIMMGLcSmqMgzTKxnrJSOz8PxPjSMUFtw==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.2.3", - "@clerk/types": "^4.50.1", + "@clerk/shared": "^3.3.0", + "@clerk/types": "^4.50.2", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" }, "engines": { "node": ">=18.17.0" + }, + "peerDependencies": { + "svix": "^1.62.0" + }, + "peerDependenciesMeta": { + "svix": { + "optional": true + } } }, "node_modules/@clerk/clerk-react": { - "version": "5.25.5", - "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.25.5.tgz", - "integrity": "sha512-euG4T9EaN4af4YH7N8Fl6hIKnXQl+KSZv1WTLgD4KP90hSpVTMPkhdWeOiRFpNQ5I6WwtkaUPY16nce5y/NTQA==", + "version": "5.25.6", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.25.6.tgz", + "integrity": "sha512-QXISFiW4xI96nIE8MEfqpy+ISjtfYa2wWYeS8Nyo+K34dK1aNpawpTopRKRirqUy2QRSF/yXaCY9IF/v22XlJg==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.2.3", - "@clerk/types": "^4.50.1", + "@clerk/shared": "^3.3.0", + "@clerk/types": "^4.50.2", "tslib": "2.8.1" }, "engines": { @@ -110,15 +128,15 @@ } }, "node_modules/@clerk/nextjs": { - "version": "6.12.12", - "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.12.12.tgz", - "integrity": "sha512-V1Vb1a5pTZArNrCy/YvpXCFQZsrRb54G+crzZ55kiuqvPaVGPguEoSCqjoaJ1RolyagXMhLKvht3Te6DYMSZEg==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.13.0.tgz", + "integrity": "sha512-xikvFU8JWBtbgh3pe76yWrlOQzIACdUNsinHP06qeRJIIg8yci8sYa93ASjd0TNPzj9cInF+owMj6mDQw7HZ5Q==", "license": "MIT", "dependencies": { - "@clerk/backend": "^1.25.8", - "@clerk/clerk-react": "^5.25.5", - "@clerk/shared": "^3.2.3", - "@clerk/types": "^4.50.1", + "@clerk/backend": "^1.26.0", + "@clerk/clerk-react": "^5.25.6", + "@clerk/shared": "^3.3.0", + "@clerk/types": "^4.50.2", "server-only": "0.0.1", "tslib": "2.8.1" }, @@ -132,18 +150,18 @@ } }, "node_modules/@clerk/shared": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.2.3.tgz", - "integrity": "sha512-F8P7SqpcaLTV/wwCB3/1AkboO3YqFjb7qS6GoSDtVTFHMfpHJgHKhZ0vUBQFaLh/8ZV1kyRuiI/hrrbwIOF1EQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.3.0.tgz", + "integrity": "sha512-hO1M5aRMzJVqkw6lWJ7NFVG5hWEnTZBUZGeHMYRwSPQzQNsgqsRMvpmaJO0Y2o0HNk50PpwZHaiFHcghUpfMiw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@clerk/types": "^4.50.1", + "@clerk/types": "^4.50.2", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", - "std-env": "^3.7.0", - "swr": "^2.2.0" + "std-env": "^3.8.1", + "swr": "^2.3.3" }, "engines": { "node": ">=18.17.0" @@ -162,9 +180,9 @@ } }, "node_modules/@clerk/types": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.50.1.tgz", - "integrity": "sha512-GwsW/6LPHavHghh2QpmDbhyIuDP61OYV0T6x5hnjgAxjfexpRymbewR7Qez7H4kOo4gtnCNUrgTZ6nyresLEEg==", + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.50.2.tgz", + "integrity": "sha512-4m1RlV/Dl3ZGW5FAXmKfdCbhF7uTDDvaADZH1F6L3d3lRBdI6i7GppK1KqscOSgoC8OwJqGaiDVUPsg+Pp8usg==", "license": "MIT", "dependencies": { "csstype": "3.1.3" @@ -1033,12 +1051,46 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", @@ -1088,6 +1140,62 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -1154,6 +1262,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", @@ -1181,6 +1304,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1248,6 +1400,106 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", @@ -1351,6 +1603,80 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", @@ -1492,6 +1818,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", @@ -1668,6 +2009,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1684,44 +2031,45 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.17.tgz", - "integrity": "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.2.tgz", + "integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==", "dev": true, "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.17" + "lightningcss": "1.29.2", + "tailwindcss": "4.1.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.17.tgz", - "integrity": "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.2.tgz", + "integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.17", - "@tailwindcss/oxide-darwin-arm64": "4.0.17", - "@tailwindcss/oxide-darwin-x64": "4.0.17", - "@tailwindcss/oxide-freebsd-x64": "4.0.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.17", - "@tailwindcss/oxide-linux-x64-musl": "4.0.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.17" + "@tailwindcss/oxide-android-arm64": "4.1.2", + "@tailwindcss/oxide-darwin-arm64": "4.1.2", + "@tailwindcss/oxide-darwin-x64": "4.1.2", + "@tailwindcss/oxide-freebsd-x64": "4.1.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.2", + "@tailwindcss/oxide-linux-x64-musl": "4.1.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.17.tgz", - "integrity": "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.2.tgz", + "integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==", "cpu": [ "arm64" ], @@ -1736,9 +2084,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.17.tgz", - "integrity": "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.2.tgz", + "integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==", "cpu": [ "arm64" ], @@ -1753,9 +2101,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.17.tgz", - "integrity": "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.2.tgz", + "integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==", "cpu": [ "x64" ], @@ -1770,9 +2118,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.17.tgz", - "integrity": "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.2.tgz", + "integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==", "cpu": [ "x64" ], @@ -1787,9 +2135,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.17.tgz", - "integrity": "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.2.tgz", + "integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==", "cpu": [ "arm" ], @@ -1804,9 +2152,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.17.tgz", - "integrity": "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.2.tgz", + "integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==", "cpu": [ "arm64" ], @@ -1821,9 +2169,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.17.tgz", - "integrity": "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.2.tgz", + "integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==", "cpu": [ "arm64" ], @@ -1838,9 +2186,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.17.tgz", - "integrity": "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.2.tgz", + "integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==", "cpu": [ "x64" ], @@ -1855,9 +2203,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.17.tgz", - "integrity": "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.2.tgz", + "integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==", "cpu": [ "x64" ], @@ -1872,9 +2220,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.17.tgz", - "integrity": "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.2.tgz", + "integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==", "cpu": [ "arm64" ], @@ -1889,9 +2237,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.17.tgz", - "integrity": "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.2.tgz", + "integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==", "cpu": [ "x64" ], @@ -1906,18 +2254,37 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.17.tgz", - "integrity": "sha512-qeJbRTB5FMZXmuJF+eePd235EGY6IyJZF0Bh0YM6uMcCI4L9Z7dy+lPuLAhxOJzxnajsbjPoDAKOuAqZRtf1PQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.2.tgz", + "integrity": "sha512-vgkMo6QRhG6uv97im6Y4ExDdq71y9v2IGZc+0wn7lauQFYJM/1KdUVhrOkexbUso8tUsMOWALxyHVkQEbsM7gw==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.0.17", - "@tailwindcss/oxide": "4.0.17", - "lightningcss": "1.29.2", + "@tailwindcss/node": "4.1.2", + "@tailwindcss/oxide": "4.1.2", "postcss": "^8.4.41", - "tailwindcss": "4.0.17" + "tailwindcss": "4.1.2" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz", + "integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/@tanstack/react-virtual": { @@ -1937,6 +2304,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", + "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.4", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.4.tgz", @@ -1975,19 +2355,18 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", - "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", - "dev": true, + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/react": { - "version": "19.0.12", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", - "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", + "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1995,9 +2374,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", - "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -2005,17 +2384,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", - "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.0.tgz", + "integrity": "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/type-utils": "8.28.0", - "@typescript-eslint/utils": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/type-utils": "8.29.0", + "@typescript-eslint/utils": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2035,16 +2414,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", - "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.0.tgz", + "integrity": "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/typescript-estree": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" }, "engines": { @@ -2060,14 +2439,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", - "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz", + "integrity": "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0" + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2078,14 +2457,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", - "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.29.0.tgz", + "integrity": "sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.28.0", - "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/utils": "8.29.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -2102,9 +2481,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", - "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.0.tgz", + "integrity": "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==", "dev": true, "license": "MIT", "engines": { @@ -2116,14 +2495,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", - "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz", + "integrity": "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2199,16 +2578,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", - "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.0.tgz", + "integrity": "sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/typescript-estree": "8.28.0" + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2223,13 +2602,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", - "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz", + "integrity": "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/types": "8.29.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2856,6 +3235,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2923,6 +3309,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -3254,6 +3650,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3548,14 +3950,14 @@ } }, "node_modules/eslint-plugin-perfectionist": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.10.1.tgz", - "integrity": "sha512-GXwFfL47RfBLZRGQdrvGZw9Ali2T2GPW8p4Gyj2fyWQ9396R/HgJMf0m9kn7D6WXRwrINfTDGLS+QYIeok9qEg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.11.0.tgz", + "integrity": "sha512-5s+ehXydnLPQpLDj5mJ0CnYj2fQe6v6gKA3tS+FZVBLzwMOh8skH+l+1Gni08rG0SdEcNhJyjQp/mEkDYK8czw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "^8.26.0", - "@typescript-eslint/utils": "^8.26.0", + "@typescript-eslint/types": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", "natural-orderby": "^5.0.0" }, "engines": { @@ -3566,14 +3968,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", - "integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.10.2" + "synckit": "^0.11.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3597,9 +3999,9 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.37.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", - "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -3613,7 +4015,7 @@ "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.8", + "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", @@ -3822,6 +4224,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3926,13 +4334,13 @@ } }, "node_modules/framer-motion": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.2.tgz", - "integrity": "sha512-7LgPRlPs5aG8UxeZiMCMZz8firC53+2+9TnWV22tuSi38D3IFRxHRUqOREKckAkt6ztX+Dn6weLcatQilJTMcg==", + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.3.tgz", + "integrity": "sha512-2hsqknz23aloK85bzMc9nSR2/JP+fValQ459ZTVElFQ0xgwR2YqNjYSuDZdFBPOwVCt4Q9jgyTt6hg6sVOALzw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.6.1", - "motion-utils": "^12.5.0", + "motion-dom": "^12.6.3", + "motion-utils": "^12.6.3", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5161,9 +5569,9 @@ } }, "node_modules/lucide-react": { - "version": "0.485.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.485.0.tgz", - "integrity": "sha512-NvyQJ0LKyyCxL23nPKESlr/jmz8r7fJO1bkuptSNYSy0s8VVj4ojhX0YAgmE1e0ewfxUZjIlZpvH+otfTnla8Q==", + "version": "0.487.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", + "integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5239,18 +5647,18 @@ } }, "node_modules/motion-dom": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.1.tgz", - "integrity": "sha512-8XVsriTUEVOepoIDgE/LDGdg7qaKXWdt+wQA/8z0p8YzJDLYL8gbimZ3YkCLlj7bB2i/4UBD/g+VO7y9ZY0zHQ==", + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.3.tgz", + "integrity": "sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.5.0" + "motion-utils": "^12.6.3" } }, "node_modules/motion-utils": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.5.0.tgz", - "integrity": "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==", + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.6.3.tgz", + "integrity": "sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg==", "license": "MIT" }, "node_modules/ms": { @@ -5387,6 +5795,26 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5457,15 +5885,16 @@ } }, "node_modules/object.entries": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", - "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -5841,6 +6270,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6018,6 +6453,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6413,9 +6854,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "license": "MIT" }, "node_modules/streamsearch": { @@ -6611,6 +7052,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.63.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.63.1.tgz", + "integrity": "sha512-1NdTJ4YI4jd8vbRLjGNg8ZCFlIb+t2iTtt1ddm+DsNKQC4GkhgjDMi7wRcXiWraBonYSlr/KARSknUW6iLM4fA==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "@types/node": "^22.7.5", + "es6-promise": "^4.2.8", + "fast-sha256": "^1.3.0", + "svix-fetch": "^3.0.0", + "url-parse": "^1.5.10" + } + }, + "node_modules/svix-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/svix-fetch/-/svix-fetch-3.0.0.tgz", + "integrity": "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/swr": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", @@ -6625,9 +7090,9 @@ } }, "node_modules/synckit": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", - "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.2.tgz", + "integrity": "sha512-1IUffI8zZ8qUMB3NUJIjk0RpLroG/8NkQDAWH1NbB2iJ0/5pn3M8rxfNzMz4GH9OnYaGYn31LEDSXJp/qIlxgA==", "dev": true, "license": "MIT", "dependencies": { @@ -6638,7 +7103,7 @@ "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, "node_modules/tabbable": { @@ -6648,9 +7113,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", - "integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.1.0.tgz", + "integrity": "sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q==", "license": "MIT", "funding": { "type": "github", @@ -6658,9 +7123,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz", - "integrity": "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.2.tgz", + "integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==", "dev": true, "license": "MIT" }, @@ -6742,6 +7207,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", @@ -6797,9 +7268,9 @@ } }, "node_modules/type-fest": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", - "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", + "version": "4.39.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.39.1.tgz", + "integrity": "sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -6901,15 +7372,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.28.0.tgz", - "integrity": "sha512-jfZtxJoHm59bvoCMYCe2BM0/baMswRhMmYhy+w6VfcyHrjxZ0OJe0tGasydCpIpA+A/WIJhTyZfb3EtwNC/kHQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.29.0.tgz", + "integrity": "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", - "@typescript-eslint/utils": "8.28.0" + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", + "@typescript-eslint/utils": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6943,10 +7414,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -6989,6 +7459,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -7041,6 +7521,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index dc5cccc..93ab002 100644 --- a/package.json +++ b/package.json @@ -21,18 +21,26 @@ "@eslint/js": "9.23.0", "@headlessui/react": "2.2.0", "@heroicons/react": "2.2.0", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "1.1.2", - "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@tanstack/react-table": "^8.21.2", "@types/canvas-confetti": "1.9.0", "autoprefixer": "10.4.21", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "crypto": "1.0.1", + "date-fns": "^4.1.0", "dayjs": "1.11.13", "framer-motion": "12.6.3", "heroicons": "2.2.0", diff --git a/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx b/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx index a923847..3ed30fc 100644 --- a/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx +++ b/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx @@ -1,15 +1,19 @@ import { Button } from '@/components/ui/button' -import { Organization } from '@clerk/nextjs/server' import { Building, User, Info, CheckCircle2 } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/navigation' +// Only require the properties we actually use +interface OrganizationData { + imageUrl?: string +} + export function OrganizationStep({ hasOrganization, organization, }: { hasOrganization: boolean - organization: Organization + organization: OrganizationData }) { const router = useRouter() diff --git a/src/app/(application)/app/projects/page.tsx b/src/app/(application)/app/projects/page.tsx new file mode 100644 index 0000000..4ac6189 --- /dev/null +++ b/src/app/(application)/app/projects/page.tsx @@ -0,0 +1,115 @@ +import { createOrUpdateUserByClerkId } from '@/app/actions/services/pocketbase/app_user_service' +import { createOrUpdateOrganizationUserMapping } from '@/app/actions/services/pocketbase/organization_app_user_service' +import { + createOrUpdateOrganizationByClerkId, + findOrganizationByClerkId, +} from '@/app/actions/services/pocketbase/organization_service' +import { getOrganizationProjects } from '@/app/actions/services/pocketbase/projectService' +import { ProjectsTable } from '@/components/app/projects/projects-table' +import { Skeleton } from '@/components/ui/skeleton' +import { auth, currentUser } from '@clerk/nextjs/server' +import { Suspense } from 'react' + +// Extended type for Clerk user with organizationMemberships +interface ClerkUserWithOrg { + id: string + firstName: string | null + lastName: string | null + emailAddresses: Array<{ emailAddress: string }> + organizationMemberships: Array<{ + role: string + organization: { + id: string + name: string + } + }> +} + +// Projects title component +function ProjectsHeader() { + return ( +
+

Projects

+

+ Manage your organization's projects and view their details. +

+
+ ) +} + +// Loading state for the projects table +function ProjectsTableSkeleton() { + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} + +// Projects content component that fetches and displays projects +async function ProjectsContent() { + // Get the current organization ID and user from Clerk + const { orgId } = await auth() + const clerkUser = await currentUser() + + if (!orgId || !clerkUser) { + throw new Error('No active organization or user found') + } + + // Cast to our extended type, but with safety checks + const user = clerkUser as unknown as Partial + + // Ensure the user record exists in PocketBase + const pbUser = await createOrUpdateUserByClerkId(user.id!, { + email: user.emailAddresses?.[0]?.emailAddress || '', + metadata: { + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + name: `${user.firstName || ''} ${user.lastName || ''}`.trim(), + }) + + // Get or create the organization record in PocketBase + let pbOrg = await findOrganizationByClerkId(orgId) + if (!pbOrg) { + // Create the organization in PocketBase with a default name + // since we might not have access to memberships + pbOrg = await createOrUpdateOrganizationByClerkId(orgId, { + name: 'My Organization', + }) + } + + // Ensure the user is properly linked to the organization with a default role + // since we might not have access to the actual role + await createOrUpdateOrganizationUserMapping(pbUser.id, pbOrg.id, 'member') + + // Fetch the projects data + const projects = await getOrganizationProjects(pbOrg.id) + + return ( +
+ +
+ ) +} + +// Main Projects page component +export default function ProjectsPage() { + return ( +
+ + }> + + +
+ ) +} diff --git a/src/app/actions/equipment/manageEquipments.ts b/src/app/actions/equipment/manageEquipments.ts index 3837ad4..aa0a535 100644 --- a/src/app/actions/equipment/manageEquipments.ts +++ b/src/app/actions/equipment/manageEquipments.ts @@ -1,13 +1,13 @@ 'use server' +import { Equipment } from '@/app/actions/services/pocketbase/api_client/types' +import { generateUniqueEquipmentCode as generateUniqueCode } from '@/app/actions/services/pocketbase/equipment_service' import { createEquipment, updateEquipment, deleteEquipment, - generateUniqueCode, -} from '@/app/actions/services/pocketbase/equipmentService' -import { SecurityError } from '@/app/actions/services/pocketbase/securityUtils' -import { Equipment } from '@/types/types_pocketbase' +} from '@/app/actions/services/pocketbase/secured/equipment_service' +import { SecurityError } from '@/app/actions/services/pocketbase/secured/security_types' import { revalidatePath } from 'next/cache' import { z } from 'zod' @@ -35,8 +35,8 @@ export type EquipmentActionResult = { /** * Convert tags array to string for PocketBase storage */ -function convertTagsForStorage(tags?: string[]): string | null { - if (!tags || tags.length === 0) return null +function convertTagsForStorage(tags?: string[]): string | undefined { + if (!tags || tags.length === 0) return undefined return JSON.stringify(tags) } @@ -55,11 +55,11 @@ export async function createEquipmentAction( const qrNfcCode = await generateUniqueCode() // Create the equipment with security checks built into the service - const newEquipment = await createEquipment(organizationId, { - acquisitionDate: validatedData.acquisitionDate || null, + const newEquipment = await createEquipment({ + acquisitionDate: validatedData.acquisitionDate || undefined, name: validatedData.name, - notes: validatedData.notes || null, - parentEquipmentId: validatedData.parentEquipment || null, + notes: validatedData.notes || undefined, + parentEquipment: validatedData.parentEquipment || undefined, qrNfcCode, tags: convertTagsForStorage(validatedData.tags), }) @@ -94,7 +94,7 @@ export async function createEquipmentAction( // Handle security errors if (error instanceof SecurityError) { return { - message: error.message, + message: (error as SecurityError).message, success: false, } } @@ -121,12 +121,15 @@ export async function updateEquipmentAction( const validatedData = equipmentSchema.parse(formData) // Update the equipment with security checks built into the service - const updatedEquipment = await updateEquipment(equipmentId, { - acquisitionDate: validatedData.acquisitionDate || null, - name: validatedData.name, - notes: validatedData.notes || null, - parentEquipmentId: validatedData.parentEquipment || null, - tags: convertTagsForStorage(validatedData.tags), + const updatedEquipment = await updateEquipment({ + data: { + acquisitionDate: validatedData.acquisitionDate || undefined, + name: validatedData.name, + notes: validatedData.notes || undefined, + parentEquipment: validatedData.parentEquipment || undefined, + tags: convertTagsForStorage(validatedData.tags), + }, + id: equipmentId, }) // Revalidate relevant paths to refresh data @@ -160,7 +163,7 @@ export async function updateEquipmentAction( // Handle security errors if (error instanceof SecurityError) { return { - message: error.message, + message: (error as SecurityError).message, success: false, } } @@ -196,7 +199,7 @@ export async function deleteEquipmentAction( // Handle security errors if (error instanceof SecurityError) { return { - message: error.message, + message: (error as SecurityError).message, success: false, } } diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts index da7153a..7cc5521 100644 --- a/src/app/actions/services/pocketbase/projectService.ts +++ b/src/app/actions/services/pocketbase/projectService.ts @@ -1,35 +1,75 @@ 'use server' import { + PocketBaseApiError, getPocketBase, - handlePocketBaseError, -} from '@/app/actions/services/pocketbase/baseService' +} from '@/app/actions/services/pocketbase/api_client/client' import { - validateOrganizationAccess, - validateResourceAccess, - createOrganizationFilter, -} from '@/app/actions/services/pocketbase/securityUtils' + Project, + ListResult, +} from '@/app/actions/services/pocketbase/api_client/types' +import { withSecurity } from '@/app/actions/services/pocketbase/secured/security_middleware' import { - PermissionLevel, - ResourceType, + SecurityContext, SecurityError, -} from '@/app/actions/services/securyUtilsTools' -import { ListOptions, ListResult, Project } from '@/types/types_pocketbase' +} from '@/app/actions/services/pocketbase/secured/security_types' + +// Interface for collection options +interface CollectionOptions { + filter?: string + sort?: string + expand?: string + skipTotal?: boolean + [key: string]: unknown +} + +// Type for PocketBase client +type PocketBaseClient = { + collection: (name: string) => { + getOne: (id: string) => Promise + getList: ( + page: number, + perPage: number, + options?: CollectionOptions + ) => Promise> + getFullList: (options?: CollectionOptions) => Promise + create: (data: Record) => Promise + update: (id: string, data: Record) => Promise + delete: (id: string) => Promise + filter: (filter: string, params: Record) => string + } + filter: (filter: string, params: Record) => string +} + +// Helper function to handle PocketBase errors +function handlePocketBaseError(error: unknown, source: string): never { + console.error(`Error in ${source}:`, error) + if (error instanceof PocketBaseApiError) { + throw error + } + if (error instanceof SecurityError) { + throw error + } + throw new Error(`Failed to execute operation in ${source}`) +} + +// Interface for list options +interface ListOptions { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} /** * Get a single project by ID with security validation */ export async function getProject(id: string): Promise { try { - // Security check - validates user has access to this resource - await validateResourceAccess(ResourceType.PROJECT, id, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - return await pb.collection('projects').getOne(id) + // Security check is now handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + return await pb.collection('Project').getOne(id) } catch (error) { if (error instanceof SecurityError) { throw error // Re-throw security errors @@ -46,13 +86,8 @@ export async function getProjectsList( options: ListOptions = {} ): Promise> { try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient const { filter: additionalFilter, @@ -62,12 +97,9 @@ export async function getProjectsList( } = options // Apply organization filter to ensure data isolation - const filter = await createOrganizationFilter( - organizationId, - additionalFilter - ) + const filter = `organization="${organizationId}"${additionalFilter ? ` && (${additionalFilter})` : ''}` - return await pb.collection('projects').getList(page, perPage, { + return await pb.collection('Project').getList(page, perPage, { ...rest, filter, }) @@ -86,18 +118,13 @@ export async function getOrganizationProjects( organizationId: string ): Promise { try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - // Apply organization filter - fixed field name - const filter = `organizationId="${organizationId}"` + // Apply organization filter - correct field name based on schema + const filter = `organization="${organizationId}"` - return await pb.collection('projects').getFullList({ + return await pb.collection('Project').getFullList({ filter, sort: 'name', }) @@ -120,20 +147,14 @@ export async function getActiveProjects( organizationId: string ): Promise { try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient const now = new Date().toISOString() // Fixed field name in filter - return await pb.collection('projects').getFullList({ + return await pb.collection('Project').getFullList({ filter: pb.filter( - 'organizationId = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', + 'organization = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', { now, orgId: organizationId } ), sort: 'name', @@ -157,18 +178,13 @@ export async function createProject( > ): Promise { try { - // Security check - requires WRITE permission - await validateOrganizationAccess(organizationId, PermissionLevel.WRITE) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient // Ensure organization ID is set correctly - fixed field name - return await pb.collection('projects').create({ + return await pb.collection('Project').create({ ...data, - organizationId, // Force the correct organization ID + organization: organizationId, // Force the correct organization ID }) } catch (error) { if (error instanceof SecurityError) { @@ -189,24 +205,15 @@ export async function updateProject( > ): Promise { try { - // Security check - requires WRITE permission - await validateResourceAccess( - ResourceType.PROJECT, - id, - PermissionLevel.WRITE - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient // Never allow changing the organization const sanitizedData = { ...data } // Fixed 'any' type and field name - delete (sanitizedData as Record).organizationId + delete (sanitizedData as Record).organization - return await pb.collection('projects').update(id, sanitizedData) + return await pb.collection('Project').update(id, sanitizedData) } catch (error) { if (error instanceof SecurityError) { throw error @@ -220,19 +227,9 @@ export async function updateProject( */ export async function deleteProject(id: string): Promise { try { - // Security check - requires ADMIN permission for deletion - await validateResourceAccess( - ResourceType.PROJECT, - id, - PermissionLevel.ADMIN - ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - await pb.collection('projects').delete(id) + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + await pb.collection('Project').delete(id) return true } catch (error) { if (error instanceof SecurityError) { @@ -247,17 +244,12 @@ export async function deleteProject(id: string): Promise { */ export async function getProjectCount(organizationId: string): Promise { try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient // Fixed field name - const result = await pb.collection('projects').getList(1, 1, { - filter: `organizationId=${organizationId}`, + const result = await pb.collection('Project').getList(1, 1, { + filter: `organization="${organizationId}"`, skipTotal: false, }) @@ -278,18 +270,13 @@ export async function searchProjects( query: string ): Promise { try { - // Security check - await validateOrganizationAccess(organizationId, PermissionLevel.READ) - - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient // Fixed field name in filter - return await pb.collection('projects').getFullList({ + return await pb.collection('Project').getFullList({ filter: pb.filter( - 'organizationId = {:orgId} && (name ~ {:query} || address ~ {:query})', + 'organization = {:orgId} && (name ~ {:query} || address ~ {:query})', { orgId: organizationId, query, @@ -304,3 +291,66 @@ export async function searchProjects( return handlePocketBaseError(error, 'ProjectService.searchProjects') } } + +// Replace these const exports with async function declarations +export async function securedGetProject(id: string) { + const securedFunc = await withSecurity( + async (id: string, context: SecurityContext) => { + // Here you would add additional security checks specific to this resource + // such as checking if the project belongs to the user's organization + + // First get the project to check if it belongs to the user's organization + const project = await getProject(id) + + // Check if the project belongs to the user's organization + if (project.organization !== context.orgPbId) { + throw new SecurityError( + 'Cannot access project from another organization' + ) + } + + return project + } + ) + + return securedFunc(id) +} + +export async function securedGetProjectsList(params: { + organizationId: string + options?: ListOptions +}) { + const securedFunc = await withSecurity( + async ( + params: { organizationId: string; options?: ListOptions }, + context: SecurityContext + ) => { + const { options, organizationId } = params + // Ensure organizationId matches the user's current organization + if (organizationId !== context.orgPbId) { + throw new SecurityError( + 'Cannot access projects from another organization' + ) + } + return getProjectsList(organizationId, options) + } + ) + + return securedFunc(params) +} + +export async function securedGetOrganizationProjects(organizationId: string) { + const securedFunc = await withSecurity( + async (organizationId: string, context: SecurityContext) => { + // Ensure organizationId matches the user's current organization + if (organizationId !== context.orgPbId) { + throw new SecurityError( + 'Cannot access projects from another organization' + ) + } + return getOrganizationProjects(organizationId) + } + ) + + return securedFunc(organizationId) +} diff --git a/src/app/actions/services/pocketbase/secured/equipment_service.ts b/src/app/actions/services/pocketbase/secured/equipment_service.ts index 575a508..6511a41 100644 --- a/src/app/actions/services/pocketbase/secured/equipment_service.ts +++ b/src/app/actions/services/pocketbase/secured/equipment_service.ts @@ -6,206 +6,263 @@ import { getEquipmentService, } from '@/app/actions/services/pocketbase/equipment_service' import { - SecurityContext, - SecurityError, - checkResourcePermission, withSecurity, + checkResourcePermission, } from '@/app/actions/services/pocketbase/secured/security_middleware' +import { + SecurityContext, + SecurityError, +} from '@/app/actions/services/pocketbase/secured/security_types' import { Equipment } from '@/models/pocketbase' /** * Get equipment by ID with security checks */ -export const getEquipmentById = withSecurity( - async (id: string, context: SecurityContext): Promise => { - // Get the equipment - const equipmentService = getEquipmentService() - const equipment = await equipmentService.getById(id) - - // Check permission - if (!checkResourcePermission(equipment.organization, context)) { - throw new SecurityError( - 'Forbidden: You do not have access to this equipment', - 403 - ) +export async function getEquipmentById(id: string): Promise { + const securedFunc = await withSecurity( + async (id: string, context: SecurityContext): Promise => { + // Get the equipment + const equipmentService = getEquipmentService() + const equipment = await equipmentService.getById(id) + + // Check permission + if (!(await checkResourcePermission(equipment.organization, context))) { + throw new SecurityError( + 'Forbidden: You do not have access to this equipment', + 403 + ) + } + + return equipment } + ) - return equipment - } -) + return securedFunc(id) +} /** * List equipment with security context */ -export const listOrganizationEquipment = withSecurity( - async ( - params: { - searchTerm?: string - page?: number - perPage?: number - sort?: string - }, - context: SecurityContext - ): Promise<{ - items: Equipment[] - totalItems: number - totalPages: number - }> => { - const equipmentService = getEquipmentService() - - // Apply the organization filter automatically - const filter = params.searchTerm - ? `organization = "${context.orgPbId}" && (name ~ "${params.searchTerm}" || tags ~ "${params.searchTerm}" || notes ~ "${params.searchTerm}")` - : `organization = "${context.orgPbId}"` - - // Get the equipment list - const result = await equipmentService.getList({ - filter, - page: params.page, - perPage: params.perPage, - sort: params.sort, - }) - - return { - items: result.items, - totalItems: result.totalItems, - totalPages: result.totalPages, +export async function listOrganizationEquipment(params: { + searchTerm?: string + page?: number + perPage?: number + sort?: string +}): Promise<{ + items: Equipment[] + totalItems: number + totalPages: number +}> { + const securedFunc = await withSecurity( + async ( + params: { + searchTerm?: string + page?: number + perPage?: number + sort?: string + }, + context: SecurityContext + ): Promise<{ + items: Equipment[] + totalItems: number + totalPages: number + }> => { + const equipmentService = getEquipmentService() + + // Apply the organization filter automatically + const filter = params.searchTerm + ? `organization = "${context.orgPbId}" && (name ~ "${params.searchTerm}" || tags ~ "${params.searchTerm}" || notes ~ "${params.searchTerm}")` + : `organization = "${context.orgPbId}"` + + // Get the equipment list + const result = await equipmentService.getList({ + filter, + page: params.page, + perPage: params.perPage, + sort: params.sort, + }) + + return { + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + } } - } -) + ) + + return securedFunc(params) +} /** * Create new equipment with security context */ -export const createEquipment = withSecurity( - async ( - data: Omit, - context: SecurityContext - ): Promise => { - const equipmentService = getEquipmentService() - - // Always set the organization to the current user's organization - const equipmentData: EquipmentCreateInput = { - ...data, - organization: context.orgPbId, - } +export async function createEquipment( + data: Omit +): Promise { + const securedFunc = await withSecurity( + async ( + data: Omit, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() - // Generate QR/NFC code if not provided - if (!equipmentData.qrNfcCode) { - equipmentData.qrNfcCode = await equipmentService.generateUniqueCode() - } + // Always set the organization to the current user's organization + const equipmentData: EquipmentCreateInput = { + ...data, + organization: context.orgPbId, + } + + // Generate QR/NFC code if not provided + if (!equipmentData.qrNfcCode) { + equipmentData.qrNfcCode = await equipmentService.generateUniqueCode() + } + + return equipmentService.create(equipmentData) + }, + { revalidatePaths: ['/app/equipment'] } + ) - return equipmentService.create(equipmentData) - }, - { revalidatePaths: ['/app/equipment'] } -) + return securedFunc(data) +} /** * Update equipment with security checks */ -export const updateEquipment = withSecurity( - async ( - params: { id: string; data: EquipmentUpdateInput }, - context: SecurityContext - ): Promise => { - const equipmentService = getEquipmentService() - - // Get the equipment first to check permissions - const existingEquipment = await equipmentService.getById(params.id) - - // Check permission - if (!checkResourcePermission(existingEquipment.organization, context)) { - throw new SecurityError( - 'Forbidden: You do not have access to this equipment', - 403 - ) - } +export async function updateEquipment(params: { + id: string + data: EquipmentUpdateInput +}): Promise { + const securedFunc = await withSecurity( + async ( + params: { id: string; data: EquipmentUpdateInput }, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() - // Prevent changing the organization - const updateData = params.data as Record - if ( - typeof updateData === 'object' && - updateData !== null && - 'organization' in updateData && - updateData.organization && - updateData.organization !== context.orgPbId - ) { - throw new SecurityError( - 'Forbidden: Cannot change equipment organization', - 403 - ) - } + // Get the equipment first to check permissions + const existingEquipment = await equipmentService.getById(params.id) - return equipmentService.update(params.id, params.data) - }, - { revalidatePaths: ['/app/equipment'] } -) + // Check permission + if ( + !(await checkResourcePermission( + existingEquipment.organization, + context + )) + ) { + throw new SecurityError( + 'Forbidden: You do not have access to this equipment', + 403 + ) + } + + // Prevent changing the organization + const updateData = params.data as Record + if ( + typeof updateData === 'object' && + updateData !== null && + 'organization' in updateData && + updateData.organization && + updateData.organization !== context.orgPbId + ) { + throw new SecurityError( + 'Forbidden: Cannot change equipment organization', + 403 + ) + } + + return equipmentService.update(params.id, params.data) + }, + { revalidatePaths: ['/app/equipment'] } + ) + + return securedFunc(params) +} /** * Delete equipment with security checks */ -export const deleteEquipment = withSecurity( - async (id: string, context: SecurityContext): Promise => { - const equipmentService = getEquipmentService() - - // Get the equipment first to check permissions - const existingEquipment = await equipmentService.getById(id) - - // Check permission (require admin for deletion) - if ( - !checkResourcePermission(existingEquipment.organization, context, true) - ) { - throw new SecurityError( - 'Forbidden: Only administrators can delete equipment', - 403 - ) +export async function deleteEquipment(id: string): Promise { + const securedFunc = await withSecurity( + async (id: string, context: SecurityContext): Promise => { + const equipmentService = getEquipmentService() + + // Get the equipment first to check permissions + const existingEquipment = await equipmentService.getById(id) + + // Check permission (require admin for deletion) + if ( + !(await checkResourcePermission( + existingEquipment.organization, + context, + true + )) + ) { + throw new SecurityError( + 'Forbidden: Only administrators can delete equipment', + 403 + ) + } + + return equipmentService.delete(id) + }, + { + requireAdmin: true, + revalidatePaths: ['/app/equipment'], } + ) - return equipmentService.delete(id) - }, - { - requireAdmin: true, - revalidatePaths: ['/app/equipment'], - } -) + return securedFunc(id) +} /** * Search equipment with security context */ -export const searchEquipment = withSecurity( - async ( - searchTerm: string, - context: SecurityContext - ): Promise => { - const equipmentService = getEquipmentService() - - // The search is already scoped to the organization - return equipmentService.search(context.orgPbId, searchTerm) - } -) +export async function searchEquipment( + searchTerm: string +): Promise { + const securedFunc = await withSecurity( + async ( + searchTerm: string, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // The search is already scoped to the organization + return equipmentService.search(context.orgPbId, searchTerm) + } + ) + + return securedFunc(searchTerm) +} /** * Find equipment by QR/NFC code with security context */ -export const findEquipmentByQrNfcCode = withSecurity( - async ( - qrNfcCode: string, - context: SecurityContext - ): Promise => { - const equipmentService = getEquipmentService() - - const equipment = await equipmentService.findByQrNfcCode(qrNfcCode) - - // If no equipment found, return null - if (!equipment) { - return null - } +export async function findEquipmentByQrNfcCode( + qrNfcCode: string +): Promise { + const securedFunc = await withSecurity( + async ( + qrNfcCode: string, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + const equipment = await equipmentService.findByQrNfcCode(qrNfcCode) + + // If no equipment found, return null + if (!equipment) { + return null + } + + // Check permission - return null if no access instead of error + if (!(await checkResourcePermission(equipment.organization, context))) { + return null + } - // Check permission - return null if no access instead of error - if (!checkResourcePermission(equipment.organization, context)) { - return null + return equipment } + ) - return equipment - } -) + return securedFunc(qrNfcCode) +} diff --git a/src/app/actions/services/pocketbase/secured/index.ts b/src/app/actions/services/pocketbase/secured/index.ts index 660fea3..432ffcf 100644 --- a/src/app/actions/services/pocketbase/secured/index.ts +++ b/src/app/actions/services/pocketbase/secured/index.ts @@ -5,7 +5,8 @@ * These services enforce permissions and access controls */ -// Security middleware +// Security types and middleware +export * from '@/app/actions/services/pocketbase/secured/security_types' export * from '@/app/actions/services/pocketbase/secured/security_middleware' // Secured services diff --git a/src/app/actions/services/pocketbase/secured/security_middleware.ts b/src/app/actions/services/pocketbase/secured/security_middleware.ts index 38b2bfd..76013d0 100644 --- a/src/app/actions/services/pocketbase/secured/security_middleware.ts +++ b/src/app/actions/services/pocketbase/secured/security_middleware.ts @@ -6,38 +6,11 @@ import { findOrganizationByClerkId } from '@/app/actions/services/pocketbase/org import { auth } from '@clerk/nextjs/server' import { revalidatePath } from 'next/cache' -/** - * Security middleware error class - */ -export class SecurityError extends Error { - statusCode: number - - constructor(message: string, statusCode = 401) { - super(message) - this.name = 'SecurityError' - this.statusCode = statusCode - } -} - -/** - * Type for the security context provided to secured actions - */ -export interface SecurityContext { - userId: string - orgId: string - orgRole: string - userPbId: string - orgPbId: string - isAdmin: boolean -} - -/** - * Type for a handler function that requires security context - */ -export type SecuredHandler = ( - params: TParams, - context: SecurityContext -) => Promise +import { + SecurityContext, + SecurityError, + SecuredHandler, +} from './security_types' /** * Higher-order function that wraps server actions with security checks @@ -46,7 +19,7 @@ export type SecuredHandler = ( * @param options - Security options * @returns A new handler function with security checks */ -export function withSecurity( +export async function withSecurity( handler: SecuredHandler, options: { revalidatePaths?: string[] @@ -86,10 +59,10 @@ export function withSecurity( // Create security context const securityContext: SecurityContext = { - isAdmin: authData.orgRole === 'admin' || userRecord.isAdmin, + isAdmin: authData.orgRole === 'admin', orgId: authData.orgId, orgPbId: orgRecord.id, - orgRole: authData.orgRole || 'member', + orgRole: authData.orgRole || '', userId: authData.userId, userPbId: userRecord.id, } @@ -128,11 +101,11 @@ export function withSecurity( * @param requireAdmin - Whether admin access is required * @returns True if the user has permission */ -export function checkResourcePermission( +export async function checkResourcePermission( resourceOrgId: string, context: SecurityContext, requireAdmin = false -): boolean { +): Promise { // Check if the resource belongs to the user's organization if (resourceOrgId !== context.orgPbId) { return false diff --git a/src/app/actions/services/pocketbase/secured/security_types.ts b/src/app/actions/services/pocketbase/secured/security_types.ts new file mode 100644 index 0000000..67ee94f --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/security_types.ts @@ -0,0 +1,32 @@ +/** + * Security middleware error class + */ +export class SecurityError extends Error { + statusCode: number + + constructor(message: string, statusCode = 401) { + super(message) + this.name = 'SecurityError' + this.statusCode = statusCode + } +} + +/** + * Type for the security context provided to secured actions + */ +export interface SecurityContext { + userId: string + orgId: string + orgRole: string + userPbId: string + orgPbId: string + isAdmin: boolean +} + +/** + * Type for a handler function that requires security context + */ +export type SecuredHandler = ( + params: TParams, + context: SecurityContext +) => Promise diff --git a/src/app/api/webhook/clerk/admin/reconcile/route.ts b/src/app/api/webhook/clerk/admin/reconcile/route.ts index 38eb324..7879f93 100644 --- a/src/app/api/webhook/clerk/admin/reconcile/route.ts +++ b/src/app/api/webhook/clerk/admin/reconcile/route.ts @@ -15,7 +15,7 @@ import { NextRequest, NextResponse } from 'next/server' */ export async function POST(req: NextRequest) { // Security checks - either admin authentication or API key - const isAuthenticated = await checkAuthentication(req) + const isAuthenticated = await checkAuthentication() if (!isAuthenticated) { return new NextResponse('Unauthorized', { status: 401 }) diff --git a/src/components/app/projects/projects-table-columns.tsx b/src/components/app/projects/projects-table-columns.tsx new file mode 100644 index 0000000..f9153e4 --- /dev/null +++ b/src/components/app/projects/projects-table-columns.tsx @@ -0,0 +1,173 @@ +'use client' + +// Import the Project interface +import { Project } from '@/app/actions/services/pocketbase/api_client/types' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { ColumnDef } from '@tanstack/react-table' +import { format, isValid } from 'date-fns' +import { EllipsisIcon, PencilIcon, TrashIcon } from 'lucide-react' + +/** + * Determines if a project is active based on its dates + */ +function isProjectActive(project: Project): boolean { + const now = new Date() + const startDate = project.startDate ? new Date(project.startDate) : null + const endDate = project.endDate ? new Date(project.endDate) : null + + if (!startDate) return false + if (startDate > now) return false + if (endDate && endDate < now) return false + + return true +} + +/** + * Format a date string to a readable format using date-fns + */ +function formatDate(dateString: string | null | undefined): string { + if (!dateString) return 'Not set' + + try { + const date = new Date(dateString) + return isValid(date) ? format(date, 'MMM d, yyyy') : 'Invalid date' + } catch { + return 'Invalid date' + } +} + +export const projectColumns: ColumnDef[] = [ + // Selection column + { + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label='Select row' + /> + ), + enableHiding: false, + enableSorting: false, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label='Select all' + /> + ), + id: 'select', + }, + // Project name column + { + accessorKey: 'name', + cell: ({ row }) => ( +
{row.getValue('name')}
+ ), + header: 'Project Name', + }, + // Project address column + { + accessorKey: 'address', + cell: ({ row }) => { + const address = row.getValue('address') as string + return address ? ( +
{address}
+ ) : ( +
No address
+ ) + }, + header: 'Address', + }, + // Project status column (derived from dates) + { + cell: ({ row }) => { + const active = isProjectActive(row.original) + return ( + + {active ? 'Active' : 'Inactive'} + + ) + }, + header: 'Status', + id: 'status', + }, + // Start date column + { + accessorKey: 'startDate', + cell: ({ row }) => { + const startDate = row.getValue('startDate') as string + return ( +
+ {formatDate(startDate)} +
+ ) + }, + header: 'Start Date', + }, + // End date column + { + accessorKey: 'endDate', + cell: ({ row }) => { + const endDate = row.getValue('endDate') as string + return ( +
+ {formatDate(endDate)} +
+ ) + }, + header: 'End Date', + }, + // Actions column + { + cell: () => { + return ( + + + + + + Actions + + + + Edit Project + + + View Assignments + + + + + + Delete Project + + + + ) + }, + id: 'actions', + }, +] diff --git a/src/components/app/projects/projects-table.tsx b/src/components/app/projects/projects-table.tsx new file mode 100644 index 0000000..5f8b8d8 --- /dev/null +++ b/src/components/app/projects/projects-table.tsx @@ -0,0 +1,259 @@ +'use client' + +import { Project } from '@/app/actions/services/pocketbase/api_client/types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Pagination, + PaginationContent, + PaginationItem, +} from '@/components/ui/pagination' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table' +import { + ChevronLeftIcon, + ChevronRightIcon, + Plus, + SearchIcon, + ChevronFirstIcon, + ChevronLastIcon, +} from 'lucide-react' +import { useState } from 'react' + +import { projectColumns } from './projects-table-columns' + +interface ProjectsTableProps { + data: Project[] + pageCount?: number +} + +export function ProjectsTable({ data }: ProjectsTableProps) { + // Table state + const [sorting, setSorting] = useState([ + { + desc: false, + id: 'name', + }, + ]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [searchQuery, setSearchQuery] = useState('') + + // Initialize the table + const table = useReactTable({ + columns: projectColumns, + data, + enableRowSelection: true, + getCoreRowModel: getCoreRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + globalFilterFn: (row, columnId, filterValue) => { + const safeValue = (() => { + const val = row.getValue(columnId) + return typeof val === 'string' + ? val.toLowerCase() + : String(val).toLowerCase() + })() + + return safeValue.includes(filterValue.toLowerCase()) + }, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onGlobalFilterChange: setSearchQuery, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + state: { + columnFilters, + columnVisibility, + globalFilter: searchQuery, + rowSelection, + sorting, + }, + }) + + return ( +
+ {/* Table header with filters and actions */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No projects found. + + + )} + +
+
+ + {/* Pagination */} +
+
+ + +
+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} +
+ + + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/src/components/comp-485.tsx b/src/components/comp-485.tsx new file mode 100644 index 0000000..7a2f0a5 --- /dev/null +++ b/src/components/comp-485.tsx @@ -0,0 +1,780 @@ +"use client" + +import { useEffect, useId, useMemo, useRef, useState } from "react" +import { + ColumnDef, + ColumnFiltersState, + FilterFn, + flexRender, + getCoreRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + Row, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { + ChevronDownIcon, + ChevronFirstIcon, + ChevronLastIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, + CircleAlertIcon, + CircleXIcon, + Columns3Icon, + EllipsisIcon, + FilterIcon, + ListFilterIcon, + PlusIcon, + TrashIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Pagination, + PaginationContent, + PaginationItem, +} from "@/components/ui/pagination" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +type Item = { + id: string + name: string + email: string + location: string + flag: string + status: "Active" | "Inactive" | "Pending" + balance: number +} + +// Custom filter function for multi-column searching +const multiColumnFilterFn: FilterFn = (row, columnId, filterValue) => { + const searchableRowContent = + `${row.original.name} ${row.original.email}`.toLowerCase() + const searchTerm = (filterValue ?? "").toLowerCase() + return searchableRowContent.includes(searchTerm) +} + +const statusFilterFn: FilterFn = ( + row, + columnId, + filterValue: string[] +) => { + if (!filterValue?.length) return true + const status = row.getValue(columnId) as string + return filterValue.includes(status) +} + +const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 28, + enableSorting: false, + enableHiding: false, + }, + { + header: "Name", + accessorKey: "name", + cell: ({ row }) => ( +
{row.getValue("name")}
+ ), + size: 180, + filterFn: multiColumnFilterFn, + enableHiding: false, + }, + { + header: "Email", + accessorKey: "email", + size: 220, + }, + { + header: "Location", + accessorKey: "location", + cell: ({ row }) => ( +
+ {row.original.flag}{" "} + {row.getValue("location")} +
+ ), + size: 180, + }, + { + header: "Status", + accessorKey: "status", + cell: ({ row }) => ( + + {row.getValue("status")} + + ), + size: 100, + filterFn: statusFilterFn, + }, + { + header: "Performance", + accessorKey: "performance", + }, + { + header: "Balance", + accessorKey: "balance", + cell: ({ row }) => { + const amount = parseFloat(row.getValue("balance")) + const formatted = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount) + return formatted + }, + size: 120, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => , + size: 60, + enableHiding: false, + }, +] + +export default function Component() { + const id = useId() + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const inputRef = useRef(null) + + const [sorting, setSorting] = useState([ + { + id: "name", + desc: false, + }, + ]) + + const [data, setData] = useState([]) + useEffect(() => { + async function fetchPosts() { + const res = await fetch( + "https://res.cloudinary.com/dlzlfasou/raw/upload/users-01_fertyx.json" + ) + const data = await res.json() + setData(data) + } + fetchPosts() + }, []) + + const handleDeleteRows = () => { + const selectedRows = table.getSelectedRowModel().rows + const updatedData = data.filter( + (item) => !selectedRows.some((row) => row.original.id === item.id) + ) + setData(updatedData) + table.resetRowSelection() + } + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + enableSortingRemoval: false, + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getFilteredRowModel: getFilteredRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + state: { + sorting, + pagination, + columnFilters, + columnVisibility, + }, + }) + + // Get unique status values + const uniqueStatusValues = useMemo(() => { + const statusColumn = table.getColumn("status") + + if (!statusColumn) return [] + + const values = Array.from(statusColumn.getFacetedUniqueValues().keys()) + + return values.sort() + }, [table.getColumn("status")?.getFacetedUniqueValues()]) + + // Get counts for each status + const statusCounts = useMemo(() => { + const statusColumn = table.getColumn("status") + if (!statusColumn) return new Map() + return statusColumn.getFacetedUniqueValues() + }, [table.getColumn("status")?.getFacetedUniqueValues()]) + + const selectedStatuses = useMemo(() => { + const filterValue = table.getColumn("status")?.getFilterValue() as string[] + return filterValue ?? [] + }, [table.getColumn("status")?.getFilterValue()]) + + const handleStatusChange = (checked: boolean, value: string) => { + const filterValue = table.getColumn("status")?.getFilterValue() as string[] + const newFilterValue = filterValue ? [...filterValue] : [] + + if (checked) { + newFilterValue.push(value) + } else { + const index = newFilterValue.indexOf(value) + if (index > -1) { + newFilterValue.splice(index, 1) + } + } + + table + .getColumn("status") + ?.setFilterValue(newFilterValue.length ? newFilterValue : undefined) + } + + return ( +
+ {/* Filters */} +
+
+ {/* Filter by name or email */} +
+ + table.getColumn("name")?.setFilterValue(e.target.value) + } + placeholder="Filter by name or email..." + type="text" + aria-label="Filter by name or email" + /> +
+
+ {Boolean(table.getColumn("name")?.getFilterValue()) && ( + + )} +
+ {/* Filter by status */} + + + + + +
+
+ Filters +
+
+ {uniqueStatusValues.map((value, i) => ( +
+ + handleStatusChange(checked, value) + } + /> + +
+ ))} +
+
+
+
+ {/* Toggle columns visibility */} + + + + + + Toggle columns + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + onSelect={(event) => event.preventDefault()} + > + {column.id} + + ) + })} + + +
+
+ {/* Delete button */} + {table.getSelectedRowModel().rows.length > 0 && ( + + + + + +
+ + + + Are you absolutely sure? + + + This action cannot be undone. This will permanently delete{" "} + {table.getSelectedRowModel().rows.length} selected{" "} + {table.getSelectedRowModel().rows.length === 1 + ? "row" + : "rows"} + . + + +
+ + Cancel + + Delete + + +
+
+ )} + {/* Add user button */} + +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : header.column.getCanSort() ? ( +
{ + // Enhanced keyboard handling for sorting + if ( + header.column.getCanSort() && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault() + header.column.getToggleSortingHandler()?.(e) + } + }} + tabIndex={header.column.getCanSort() ? 0 : undefined} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: ( +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} +
+ ) + })} +
+ ))} +
+ + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ + {/* Pagination */} +
+ {/* Results per page */} +
+ + +
+ {/* Page number information */} +
+

+ + {table.getState().pagination.pageIndex * + table.getState().pagination.pageSize + + 1} + - + {Math.min( + Math.max( + table.getState().pagination.pageIndex * + table.getState().pagination.pageSize + + table.getState().pagination.pageSize, + 0 + ), + table.getRowCount() + )} + {" "} + of{" "} + + {table.getRowCount().toString()} + +

+
+ + {/* Pagination buttons */} +
+ + + {/* First page button */} + + + + {/* Previous page button */} + + + + {/* Next page button */} + + + + {/* Last page button */} + + + + + +
+
+

+ Example of a more complex table made with{" "} + + TanStack Table + +

+
+ ) +} + +function RowActions({ row }: { row: Row }) { + return ( + + +
+ +
+
+ + + + Edit + ⌘E + + + Duplicate + ⌘D + + + + + + Archive + ⌘A + + + More + + + Move to project + Move to folder + + Advanced options + + + + + + + Share + Add to favorites + + + + Delete + ⌘⌫ + + +
+ ) +} diff --git a/src/components/magicui/confetti.tsx b/src/components/magicui/confetti.tsx index c386e5a..8a8ed77 100644 --- a/src/components/magicui/confetti.tsx +++ b/src/components/magicui/confetti.tsx @@ -7,7 +7,8 @@ import type { } from 'canvas-confetti' import type { ReactNode } from 'react' -import { Button, ButtonProps } from '@/components/ui/button' +import { Button } from '@/components/ui/button' +import { ButtonProps } from '@headlessui/react' import confetti from 'canvas-confetti' import React, { createContext, @@ -109,7 +110,7 @@ ConfettiComponent.displayName = 'Confetti' // Export as Confetti export const Confetti = ConfettiComponent -interface ConfettiButtonProps extends ButtonProps { +interface ConfettiButtonProps { options?: ConfettiOptions & ConfettiGlobalOptions & { canvas?: HTMLCanvasElement } children?: React.ReactNode diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..7935a73 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..81c46be --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-1.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-[color,box-shadow] [&>svg]:shrink-0 leading-normal", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..bbfeb75 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,59 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + {props.checked === "indeterminate" ? ( + + + + ) : ( + + + + )} + + + ) +} + +export { Checkbox } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..3025ceb --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,309 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +type PointerDownEvent = Parameters< + NonNullable +>[0] +type PointerDownOutsideEvent = Parameters< + NonNullable< + DropdownMenuPrimitive.DropdownMenuContentProps["onPointerDownOutside"] + > +>[0] + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + onPointerDown, + onPointerDownOutside, + onCloseAutoFocus, + ...props +}: React.ComponentProps) { + const isCloseFromMouse = React.useRef(false) + + const handlePointerDown = React.useCallback( + (e: PointerDownEvent) => { + isCloseFromMouse.current = true + onPointerDown?.(e) + }, + [onPointerDown] + ) + + const handlePointerDownOutside = React.useCallback( + (e: PointerDownOutsideEvent) => { + isCloseFromMouse.current = true + onPointerDownOutside?.(e) + }, + [onPointerDownOutside] + ) + + const handleCloseAutoFocus = React.useCallback( + (e: Event) => { + if (onCloseAutoFocus) { + return onCloseAutoFocus(e) + } + + if (!isCloseFromMouse.current) { + return + } + + e.preventDefault() + isCloseFromMouse.current = false + }, + [onCloseAutoFocus] + ) + + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..5d66afe --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..32a207b --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +