diff --git a/dataconnect/connector/event/queries.gql b/dataconnect/connector/event/queries.gql index c8c1c98b..3fe4233e 100644 --- a/dataconnect/connector/event/queries.gql +++ b/dataconnect/connector/event/queries.gql @@ -35,6 +35,7 @@ query listEvents @auth(level: USER) { notes requested assignedStaff + createdBy } } @@ -143,5 +144,6 @@ query filterEvents( notes requested assignedStaff + createdBy } } diff --git a/dataconnect/connector/staff/mutations.gql b/dataconnect/connector/staff/mutations.gql index 20d26162..64f2954a 100644 --- a/dataconnect/connector/staff/mutations.gql +++ b/dataconnect/connector/staff/mutations.gql @@ -12,9 +12,11 @@ mutation CreateStaff( $profileType: ProfileType, $employmentType: EmploymentType!, $english: EnglishLevel, + $rate: Float, $rating: Float, $reliabilityScore: Int, $backgroundCheckStatus: BackgroundCheckStatus! + $notes: String ) @auth(level: USER) { staff_insert( data: { @@ -31,9 +33,11 @@ mutation CreateStaff( profileType: $profileType employmentType: $employmentType english: $english + rate: $rate rating: $rating reliabilityScore: $reliabilityScore backgroundCheckStatus: $backgroundCheckStatus + notes: $notes } ) } @@ -53,9 +57,11 @@ mutation UpdateStaff( $profileType: ProfileType, $employmentType: EmploymentType, $english: EnglishLevel, + $rate: Float, $rating: Float, $reliabilityScore: Int, $backgroundCheckStatus: BackgroundCheckStatus + $notes: String ) @auth(level: USER) { staff_update( id: $id, @@ -73,9 +79,11 @@ mutation UpdateStaff( profileType: $profileType employmentType: $employmentType english: $english + rate: $rate rating: $rating reliabilityScore: $reliabilityScore backgroundCheckStatus: $backgroundCheckStatus + notes: $notes } ) } diff --git a/dataconnect/connector/staff/queries.gql b/dataconnect/connector/staff/queries.gql index da35030a..87ed5235 100644 --- a/dataconnect/connector/staff/queries.gql +++ b/dataconnect/connector/staff/queries.gql @@ -14,9 +14,11 @@ query listStaff @auth(level: USER) { profileType employmentType english + rate rating reliabilityScore backgroundCheckStatus + notes } } @@ -38,9 +40,11 @@ query getStaffById( profileType employmentType english + rate rating reliabilityScore backgroundCheckStatus + notes } } @@ -70,8 +74,10 @@ query filterStaff( position employmentType english + rate rating reliabilityScore backgroundCheckStatus + notes } } diff --git a/dataconnect/connector/user/mutations.gql b/dataconnect/connector/user/mutations.gql new file mode 100644 index 00000000..eeedba82 --- /dev/null +++ b/dataconnect/connector/user/mutations.gql @@ -0,0 +1,41 @@ +mutation CreateUser( + $id: String!, # Firebase UID + $email: String!, + $fullName: String!, + $role: UserBaseRole!, + $userRole: String +) @auth(level: USER) { + user_insert( + data: { + id: $id + email: $email + fullName: $fullName + role: $role + userRole: $userRole + } + ) +} + +mutation UpdateUser( + $id: String!, + $email: String, + $fullName: String, + $role: UserBaseRole, + $userRole: String +) @auth(level: USER) { + user_update( + id: $id, + data: { + email: $email + fullName: $fullName + role: $role + userRole: $userRole + } + ) +} + +mutation DeleteUser( + $id: String! +) @auth(level: USER) { + user_delete(id: $id) +} diff --git a/dataconnect/connector/user/queries.gql b/dataconnect/connector/user/queries.gql new file mode 100644 index 00000000..d761fdc6 --- /dev/null +++ b/dataconnect/connector/user/queries.gql @@ -0,0 +1,45 @@ +query listUsers @auth(level: USER) { + users { + id + email + fullName + role + userRole + createdDate + updatedDate + } +} + +query getUserById( + $id: String! +) @auth(level: USER) { + user(id: $id) { + id + email + fullName + role + userRole + } +} + +query filterUsers( + $id: String, + $email: String, + $role: UserBaseRole, + $userRole: String +) @auth(level: USER) { + users( + where: { + id: { eq: $id } + email: { eq: $email } + role: { eq: $role } + userRole: { eq: $userRole } + } + ) { + id + email + fullName + role + userRole + } +} diff --git a/dataconnect/schema/staff.gql b/dataconnect/schema/staff.gql index 3a0bab4e..a4aaf878 100644 --- a/dataconnect/schema/staff.gql +++ b/dataconnect/schema/staff.gql @@ -42,22 +42,24 @@ enum BackgroundCheckStatus { type Staff @table(name: "staffs") { id: UUID! @default(expr: "uuidV4()") - employeeName: String! - vendorId: UUID # vendor_id (FK lógica a Vendor.id) - vendorName: String + employeeName: String! @col(name: "employee_name") + vendorId: UUID @col(name: "vendor_id") # vendor_id (FK lógica a Vendor.id) + vendorName: String @col(name: "vendor_name") manager: String - contactNumber: String + contactNumber: String @col(name: "contact_number") email: String department: StaffDepartment - hubLocation: String + hubLocation: String @col(name: "hub_location") track: String position: String - profileType: ProfileType - employmentType: EmploymentType! + profileType: ProfileType @col(name: "profile_type") + employmentType: EmploymentType! @col(name: "employment_type") english: EnglishLevel + rate: Float rating: Float - reliabilityScore: Int - backgroundCheckStatus: BackgroundCheckStatus! # background_check_status + reliabilityScore: Int @col(name: "reliability_score") + backgroundCheckStatus: BackgroundCheckStatus! @col(name: "background_check_status") + notes: String createdDate: Timestamp @default(expr: "request.time") updatedDate: Timestamp @default(expr: "request.time") createdBy: String @default(expr: "auth.uid") diff --git a/dataconnect/schema/user.gql b/dataconnect/schema/user.gql new file mode 100644 index 00000000..ec927890 --- /dev/null +++ b/dataconnect/schema/user.gql @@ -0,0 +1,15 @@ +enum UserBaseRole { + ADMIN + USER +} + +type User @table(name: "users") { + id: String! # user_id / uid de Firebase + email: String! + fullName: String! + role: UserBaseRole! + userRole: String + createdDate: Timestamp @default(expr: "request.time") + updatedDate: Timestamp @default(expr: "request.time") + createdBy: String @default(expr: "auth.uid") +} diff --git a/frontend-web/dataconnect-naming-and-enum-findings.md b/frontend-web/dataconnect-naming-and-enum-findings.md new file mode 100644 index 00000000..d2c11760 --- /dev/null +++ b/frontend-web/dataconnect-naming-and-enum-findings.md @@ -0,0 +1,253 @@ +# DataConnect – Inconsistencies between the Base44 frontend and the backend (Firebase Data Connect + PostgreSQL) +**Author:** José Salazar +**Date:** Dec 2025 + +--- + +## 📌 Purpose of this document + +Document all findings during the integration of the new backend +(**Firebase Data Connect + PostgreSQL**) with the frontend generated by **Base44 AI**. + +This document summarizes: + +- Issues found +- camelCase vs snake_case inconsistencies +- Enum inconsistencies (uppercase/lowercase and dashes) +- Differences between what DataConnect returns vs what the frontend expects +- Recommended fixes +- Suggestions for Base44 AI to update its model +- Impact on queries, mutations, and the generated SDK + +--- + +## 1️⃣ Enums – Different formats between Front and Backend + +### Observation + +In the frontend, enum values are in mixed formats, for example: + +- UPPERCASE: SKILLED +- camelCase: fullTime + +In the backend (DataConnect schema), enums are defined only in UPPERCASE, for example: + +- FULL_TIME +- CROSS_TRAINED +- NOT_REQUIRED +- PENDING + +DataConnect only accepts these exact values. + +### Problem + +When the frontend sends: + +- "crossTrained" instead of CROSS_TRAINED +- "fluent" instead of FLUENT + +### Impact + +- Mutations can fail or return enum validation errors. +- Filters using enums return no results. +- Behavior changes depending on how Base44 AI generated the object. + +### Recommendation + +- Define a single standard: **ALL enums must be UPPERCASE** on the frontend and backend. +- Before sending to the backend, normalize enum values to uppercase. + +### Suggestion for Base44 AI + +- Adjust models so they always generate enums in UPPERCASE. + +--- + +## 2️⃣ Enums with dashes (“-”) – Not valid in GraphQL + +### Observation + +In the legacy frontend, some enum values contain dashes, for example: + +- CUSTOMER-SERVICE +- CROSS-TRAINED +- PART-TIME + +But in GraphQL enums only allow letters, numbers, and underscores. +The backend had to define them as: + +- CUSTOMER_SERVICE +- CROSS_TRAINED +- PART_TIME + +### Problem + +When the frontend sends "CUSTOMER-SERVICE" or "CROSS-TRAINED": + +- The backend expects CUSTOMER_SERVICE or CROSS_TRAINED. +- There is no match between the frontend value and the DataConnect enum. + +### Impact + +- Enum filters return nothing. +- Mutations fail when trying to save invalid enum values. +- Compatibility between the Base44 model and the DataConnect schema breaks. + +### Recommendation + +- Standardize all enums to UPPERCASE SNAKE_CASE (e.g., CUSTOMER_SERVICE). +- Never use dashes “-” in enum values. + +### Suggestion for Base44 AI + +- Update models so enum values are always generated as + UPPERCASE_WITH_UNDERSCORE (e.g., CUSTOMER_SERVICE), without dashes. + +--- + +## 3️⃣ Field names – Front in snake_case vs DataConnect in camelCase + +### Observation + +The original Base44 frontend uses snake_case field names, for example: + +- contact_number +- vendor_id +- background_check_status +- hub_location + +In DataConnect the schema is camelCase, and although you can map to the actual PostgreSQL column using @col, the GraphQL type remains camelCase, for example: + +- contactNumber (mapped to "contact_number" in Postgres) +- vendorId (mapped to "vendor_id") +- backgroundCheckStatus (mapped to "background_check_status") +- hubLocation (mapped to "hub_location") + +Meaning: + +- In the database (PostgreSQL) names remain snake_case. +- In DataConnect and the SDK they are exposed as camelCase. + +### Problem + +The frontend still expects/reads fields like contact_number, but the SDK returns contactNumber. +A similar issue happens when the frontend sends payloads in snake_case: + +- The GraphQL schema does not recognize contact_number. +- It only accepts contactNumber. + +### Impact + +- UI fails to show data because it reads keys that don’t exist (snake_case). +- Mutations fail or ignore fields due to mismatched names. +- Filters with snake_case are invalid in GraphQL. + +### Recommendation + +- Agree that **all communication with DataConnect (frontend + SDK) uses camelCase**. +- Keep snake_case only at PostgreSQL level using @col, for example: + + employeeName: String @col(name: "employee_name") + +Thus: + +- Frontend / SDK / GraphQL → camelCase (employeeName) +- PostgreSQL → snake_case (employee_name) + +### Suggestion for Base44 AI + +- Adjust generated frontend code so it uses camelCase when consuming the new backend. +- If Supabase or another backend is still used, document all mappings clearly. + +--- + +## 4️⃣ Fields used by the frontend but not listed in API Spec v3 + +### Observation + +During integration we found that Base44 frontend uses fields not defined in the official document: + +Reference file: +docs/03-backend-api-specification-v3.md + +Examples in the Staff entity: + +The frontend sends and displays fields like: + +- notes +- rate + +But these fields were not defined in the original v3 specification for Staff. + +### Problem + +- The frontend assumes these fields exist because the old database had them. +- The DataConnect schema does not include them. +- Sending these values in mutations causes validation errors. + +### Impact + +- Inconsistency between what the UI shows/edits and what is actually persisted. +- Risk of losing data the user believes is being saved. +- Hard to maintain a 1:1 mapping between the previous Base44 model and the new backend. + +### Recommendation + +- Validate which fields should truly exist for each entity (e.g., Staff). +- Align these three items: + 1. API Spec v3 + 2. DataConnect Schema + 3. Base44 Frontend + +- If a field is no longer needed, remove it from the frontend. +- If important, add it formally to the API Spec and to the DataConnect schema. + +--- + +## 5️⃣ DataConnect vs Front – Observed behavior + +1. DataConnect: + - Always exposes fields in camelCase. + - Enforces enum restrictions exactly as defined (UPPERCASE, no dashes). + - Allows mapping to Postgres column names using @col, but GraphQL names remain camelCase. + +2. Base44 Frontend: + - Uses snake_case in many areas. + - Uses enums in mixed formats (uppercase, camelCase, with dashes). + - Contains extra fields not included in API Spec v3. + +--- + +## 6️⃣ Suggested fixes (personal criteria) + +1. Enums + - Standardize to UPPERCASE_SNAKE_CASE for all enum values. + - Apply a normalization layer in the frontend to convert any format into the official one before hitting the backend. + +2. Field names + - Migrate the frontend to camelCase for any interaction with DataConnect. + - Keep snake_case only at the database layer using @col. + +3. Extra fields + - Review all fields the frontend sends and compare with API Spec v3. + - Remove or add fields depending on what becomes the “source of truth” (Spec v3). + +4. Documentation + - Keep this file updated as the Base44 → DataConnect migration reference. + - Add valid payload examples for each entity (Staff, Vendor, Invoice, etc.). + +--- + +## 7️⃣ Summary + +- Always generate: + - Enums: UPPERCASE_SNAKE_CASE (e.g., FULL_TIME, CUSTOMER_SERVICE). + - Fields: camelCase (e.g., contactNumber, hubLocation, backgroundCheckStatus). + +- Avoid: + - Dashes “-” in enum values. + - Spaces in enum values. + +--- + +This document captures the current findings and serves as a guide to fully align the Base44 frontend with the backend based on Firebase Data Connect + PostgreSQL. diff --git a/frontend-web/src/api/client.js b/frontend-web/src/api/client.js new file mode 100644 index 00000000..e63a00f7 --- /dev/null +++ b/frontend-web/src/api/client.js @@ -0,0 +1,22 @@ +import axios from "axios"; +import { auth } from "../firebase"; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, // You will need to add this to your .env file +}); + +apiClient.interceptors.request.use( + async (config) => { + const user = auth.currentUser; + if (user) { + const token = await user.getIdToken(); + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +export default apiClient; \ No newline at end of file diff --git a/frontend-web/src/api/krowSDK.js b/frontend-web/src/api/krowSDK.js new file mode 100644 index 00000000..f1923ce8 --- /dev/null +++ b/frontend-web/src/api/krowSDK.js @@ -0,0 +1,491 @@ +import apiClient from './client'; +import { auth, dataConnect } from '../firebase'; +import { signOut } from 'firebase/auth'; + +import * as dcSdk from '@dataconnect/generated'; // listEvents, createEvent, etc. + +// --- Auth Module --- +const authModule = { + /** + * Fetches the currently authenticated user's profile from the backend. + * @returns {Promise} The user profile. + */ + me: async () => { + // 1. Firebase auth user + const fbUser = auth.currentUser; + + if (!fbUser) { + return null; // NO ESTÁ LOGGEADO + } + + // 2. Attempt to load matching Krow User from DataConnect + // (because your Krow user metadata is stored in the "users" table) + let krowUser = null; + try { + const response = await dcSdk.getUser(dataConnect, { id: fbUser.uid }); + krowUser = response.data?.user || null; + } catch (err) { + console.warn("Krow user not found in DataConnect, returning Firebase-only info."); + } + + // 3. Build unified "me" object + return { + id: fbUser.uid, + email: fbUser.email, + fullName: krowUser?.fullName || fbUser.displayName || null, + role: krowUser?.role || "user", + user_role: krowUser?.userRole || null, + firebase: fbUser, + krow: krowUser + }; + }, + + /** + * Logs the user out. + * @param {string} [redirectUrl] - Optional URL to redirect to after logout. + */ + logout: async (redirectUrl) => { + await signOut(auth); + if (redirectUrl) { + window.location.href = redirectUrl; + } + }, + + /** + * Checks if a user is currently authenticated. + * @returns {boolean} True if a user is authenticated. + */ + isAuthenticated: () => { + return !!auth.currentUser; + }, +}; + +// --- Core Integrations Module --- +const coreIntegrationsModule = { + /** + * Sends an email. + * @param {object} params - { to, subject, body } + * @returns {Promise} API response. + */ + SendEmail: async (params) => { + const { data } = await apiClient.post('/sendEmail', params); + return data; + }, + + /** + * Invokes a large language model. + * @param {object} params - { prompt, response_json_schema, file_urls } + * @returns {Promise} API response. + */ + InvokeLLM: async (params) => { + const { data } = await apiClient.post('/invokeLLM', params); + return data; + }, + + /** + * Uploads a public file. + * @param {File} file - The file to upload. + * @returns {Promise} API response with file_url. + */ + UploadFile: async ({ file }) => { + const formData = new FormData(); + formData.append('file', file); + const { data } = await apiClient.post('/uploadFile', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; + }, + + /** + * Uploads a private file. + * @param {File} file - The file to upload. + * @returns {Promise} API response with file_uri. + */ + UploadPrivateFile: async ({ file }) => { + const formData = new FormData(); + formData.append('file', file); + const { data } = await apiClient.post('/uploadPrivateFile', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; + }, + + /** + * Creates a temporary signed URL for a private file. + * @param {object} params - { file_uri, expires_in } + * @returns {Promise} API response with signed_url. + */ + CreateFileSignedUrl: async (params) => { + const { data } = await apiClient.post('/createSignedUrl', params); + return data; + }, +}; + +const dataconnectEntityConfig = { + User: { + list: 'listUsers', + get: 'getUserById', + create: 'createUser', + update: 'updateUser', + delete: 'deleteUser', + filter: 'filterUsers', + }, + Event: { + list: 'listEvents', + create: 'createEvent', + get: 'getEventById', + update: 'updateEvent', + delete: 'deleteEvent', + filter: 'filterEvents', + }, + + Staff: { + list: 'listStaff', + create: 'createStaff', + get: 'getStaffById', + update: 'updateStaff', + delete: 'deleteStaff', + filter: 'filterStaff', + }, + + Vendor: { + list: 'listVendor', + get: 'getVendorById', + create: 'createVendor', + update: 'updateVendor', + delete: 'deleteVendor', + filter: 'filterVendors', + }, + + VendorRate: { + list: 'listVendorRate', + get: 'getVendorRateById', + create: 'createVendorRate', + update: 'updateVendorRate', + delete: 'deleteVendorRate', + filter: 'filterVendorRates', + }, + + VendorDefaultSetting:{ + list: 'listVendorDefaultSettings', + get: 'getVendorDefaultSettingById', + create: 'createVendorDefaultSetting', + update: 'updateVendorDefaultSetting', + delete: 'deleteVendorDefaultSetting', + filter: 'filterVendorDefaultSettings', + }, + + Invoice:{ + list: 'listInvoice', + get: 'getInvoiceById', + create: 'createInvoice', + update: 'updateInvoice', + delete: 'deleteInvoice', + filter: 'filterInvoices', + + }, + + Business:{ + list: 'listBusiness', + get: 'getBusinessById', + create: 'createBusiness', + update: 'updateBusiness', + delete: 'deleteBusiness', + filter: 'filterBusiness', + }, + + Certification:{ + list: 'listCertification', + get: 'getCertificationById', + create: 'createCertification', + update: 'updateCertification', + delete: 'deleteCertification', + filter: 'filterCertification', + }, + + Team:{ + list: 'listTeam', + get: 'getTeamById', + create: 'createTeam', + update: 'updateTeam', + delete: 'deleteTeam', + filter: 'filterTeam', + }, + + TeamMember: { + list: 'listTeamMember', + get: 'getTeamMemberById', + create: 'createTeamMember', + update: 'updateTeamMember', + delete: 'deleteTeamMember', + filter: 'filterTeamMember', + }, + + TeamHub: { + list: 'listTeamHub', + get: 'getTeamHubById', + create: 'createTeamHub', + update: 'updateTeamHub', + delete: 'deleteTeamHub', + filter: 'filterTeamHub', + }, + + TeamMemberInvite: { + list: 'listTeamMemberInvite', + get: 'getTeamMemberInviteById', + create: 'createTeamMemberInvite', + update: 'updateTeamMemberInvite', + delete: 'deleteTeamMemberInvite', + filter: 'filterTeamMemberInvite', + }, + + Conversation:{ + list: 'listConversation', + get: 'getConversationById', + create: 'createConversation', + update: 'updateConversation', + delete: 'deleteConversation', + filter: 'filterConversation', + }, + + Message:{ + list: 'listMessage', + get: 'getMessageById', + create: 'createMessage', + update: 'updateMessage', + delete: 'deleteMessage', + filter: 'filterMessage', + }, + + ActivityLog:{ + list: 'listActivityLog', + get: 'getActivityLogById', + create: 'createActivityLog', + update: 'updateActivityLog', + delete: 'deleteActivityLog', + filter: 'filterActivityLog', + }, + + Enterprise:{ + list: 'listEnterprise', + get: 'getEnterpriseById', + create: 'createEnterprise', + update: 'updateEnterprise', + delete: 'deleteEnterprise', + filter: 'filterEnterprise', + }, + + Sector:{ + + }, + + Partner:{ + + }, + + Order:{ + + }, + + Shift:{ + + } + +}; + +// Helper for methods not implemented +const notImplemented = (entityName, method) => async () => { + throw new Error(`${entityName}.${method} is not implemented yet for Data Connect`); +}; + +// --- Entities Module ( Data Connect, without REST Base44) --- +const entitiesModule = {}; + +Object.entries(dataconnectEntityConfig).forEach(([entityName, ops]) => { + entitiesModule[entityName] = { + + get: notImplemented(entityName, 'get'), + update: notImplemented(entityName, 'update'), + delete: notImplemented(entityName, 'delete'), + filter: notImplemented(entityName, 'filter'), + list: notImplemented(entityName, 'list'), + create: notImplemented(entityName, 'create'), + + // list + ...(ops.list && { + list: async (params) => { + const fn = dcSdk[ops.list]; + if (typeof fn !== 'function') { + throw new Error( + `Data Connect operation "${ops.list}" not found for entity "${entityName}".` + ); + } + + return fn(dataConnect); + }, + }), + + // create + ...(ops.create && { + create: async (params) => { + const fn = dcSdk[ops.create]; + if (typeof fn !== 'function') { + throw new Error( + `Data Connect operation "${ops.create}" not found for entity "${entityName}".` + ); + } + + const { data } = params ?? {}; + if (!data) { + throw new Error( + `${entityName}.create expects a payload like { data: { ...fields } }` + ); + } + + return fn(dataConnect, data); + }, + }), + + //get + ...(ops.get && { + get: async (params) => { + const fn = dcSdk[ops.get]; + if (typeof fn !== 'function') { + throw new Error( + `Data Connect operation "${ops.get}" not found for entity "${entityName}".` + ); + } + + if (!params || typeof params !== 'object') { + throw new Error(`${entityName}.get expects an object of variables (e.g. { id })`); + } + + return fn(dataConnect, params); + }, + }), + + //update + ...(ops.update && { + update: async (params) => { + const fn = dcSdk[ops.update]; + if (typeof fn !== 'function') { + throw new Error( + `Data Connect operation "${ops.update}" not found for entity "${entityName}".` + ); + } + + if (!params || typeof params !== 'object') { + throw new Error( + `${entityName}.update expects an object of variables matching the GraphQL mutation` + ); + } + + const { id, data } = params; + + if (!id) { + throw new Error(`${entityName}.update requires an "id" field`); + } + + if (!data || typeof data !== 'object') { + throw new Error( + `${entityName}.update requires a "data" object with the fields to update` + ); + } + + const vars = { id, ...data }; + + return fn(dataConnect, vars); + + }, + }), + + + // delete + ...(ops.delete && { + delete: async (params) => { + const fn = dcSdk[ops.delete]; + if (typeof fn !== 'function') { + throw new Error( + `Data Connect operation "${ops.delete}" not found for entity "${entityName}".` + ); + } + + if (!params || typeof params !== 'object') { + throw new Error( + `${entityName}.delete expects an object like { id }` + ); + } + + const { id } = params; + + if (!id) { + throw new Error(`${entityName}.delete requires an "id" field`); + } + + // Data Connect solo espera { id } como variables + return fn(dataConnect, { id }); + }, + }), + + + // filter + ...(ops.filter && { + filter: async (params) => { + const fn = dcSdk[ops.filter]; + if (typeof fn !== 'function') { + throw new Error( + `Data Connect operation "${ops.filter}" not found for entity "${entityName}".` + ); + } + + if (!params) { + if (ops.list) {//if no params, call to list() + const listFn = dcSdk[ops.list]; + if (typeof listFn !== 'function') { + throw new Error( + `Data Connect operation "${ops.list}" not found for entity "${entityName}".` + ); + } + return listFn(dataConnect); + } + throw new Error(`${entityName}.filter expects params or a list operation`); + } + const rawFilters = params.filters ?? params; + const variables = {}; + + for (const [key, value] of Object.entries(rawFilters)) {//cleaning undefined/null/'' values + if (value !== undefined && value !== null && value !== '') { + variables[key] = value; + } + } + + // if no valid filters, call to list() + if (Object.keys(variables).length === 0) { + if (ops.list) { + const listFn = dcSdk[ops.list]; + if (typeof listFn !== 'function') { + throw new Error( + `Data Connect operation "${ops.list}" not found for entity "${entityName}".` + ); + } + return listFn(dataConnect); + } + throw new Error(`${entityName}.filter received no valid filters and no list operation`); + } + + return fn(dataConnect, variables); + }, + }), + + }; +}); + + +// --- Main SDK Export --- +export const krowSDK = { + auth: authModule, + integrations: { + Core: coreIntegrationsModule, + }, + entities: entitiesModule, +}; diff --git a/frontend-web/src/components/auth/ProtectedRoute.jsx b/frontend-web/src/components/auth/ProtectedRoute.jsx new file mode 100644 index 00000000..22384a45 --- /dev/null +++ b/frontend-web/src/components/auth/ProtectedRoute.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import { Loader2 } from 'lucide-react'; + +export default function ProtectedRoute({ children }) { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + if (!user) { + return ; + } + + return children; +} \ No newline at end of file diff --git a/frontend-web/src/components/auth/PublicRoute.jsx b/frontend-web/src/components/auth/PublicRoute.jsx new file mode 100644 index 00000000..55d4d0d8 --- /dev/null +++ b/frontend-web/src/components/auth/PublicRoute.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import { Loader2 } from 'lucide-react'; + +export default function PublicRoute({ children }) { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + if (user) { + return ; + } + + return children; +} \ No newline at end of file diff --git a/frontend-web/src/components/ui/use-toast.jsx b/frontend-web/src/components/ui/use-toast.jsx index cc33246c..5ad1caad 100644 --- a/frontend-web/src/components/ui/use-toast.jsx +++ b/frontend-web/src/components/ui/use-toast.jsx @@ -1,5 +1,5 @@ import React from "react" -import { base44 } from "@/api/base44Client"; +import { krowSDK } from "@/api/krowSDK"; const TOAST_LIMIT = 5 const TOAST_REMOVE_DELAY = 1000000 @@ -95,7 +95,12 @@ function dispatch(action) { // Helper function to create notification in ActivityLog instead of toast async function createNotification(title, description, variant) { try { - const user = await base44.auth.me(); + const user = await krowSDK.auth.me(); + + if (!user) { + console.warn("Cannot create notification: user not authenticated."); + return; + } // Determine icon and color based on variant and title let icon_type = "check"; @@ -124,7 +129,7 @@ async function createNotification(title, description, variant) { activity_type = "staff_assigned"; } - await base44.entities.ActivityLog.create({ + const payload = { title: title.replace(/✅|❌|⚠️/g, '').trim(), description: description || "", activity_type: activity_type, @@ -132,7 +137,10 @@ async function createNotification(title, description, variant) { is_read: false, icon_type: icon_type, icon_color: icon_color, - }); + }; + + await krowSDK.entities.ActivityLog.create({ data: payload }); + } catch (error) { console.error("Failed to create notification:", error); } diff --git a/frontend-web/src/firebase.js b/frontend-web/src/firebase.js new file mode 100644 index 00000000..26d64d59 --- /dev/null +++ b/frontend-web/src/firebase.js @@ -0,0 +1,29 @@ +// Import the functions you need from the SDKs you need +import { initializeApp, getApps, getApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; +import { getDataConnect } from 'firebase/data-connect'; +import { connectorConfig } from '@dataconnect/generated'; + +// Your web app's Firebase configuration +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID +}; + +// Initialize Firebase app only once +let app; +if (getApps().length === 0) { // Check if no app is already initialized + app = initializeApp(firebaseConfig); +} else { + app = getApp(); // If already initialized, use the existing app +} + +export const dataConnect = getDataConnect(app, connectorConfig); +export const auth = getAuth(app); + +// For debugging purposes, if you previously added this, it can stay or be removed +// window.firebaseAuth = auth; \ No newline at end of file diff --git a/frontend-web/src/hooks/useAuth.js b/frontend-web/src/hooks/useAuth.js new file mode 100644 index 00000000..51d3b33e --- /dev/null +++ b/frontend-web/src/hooks/useAuth.js @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; +import { onAuthStateChanged } from 'firebase/auth'; +import { auth } from '@/firebase'; + +export function useAuth() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (user) => { + setUser(user); + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + + return { user, loading }; +} \ No newline at end of file diff --git a/frontend-web/src/pages/AddStaff.jsx b/frontend-web/src/pages/AddStaff.jsx index 807f402b..c1a3bba5 100644 --- a/frontend-web/src/pages/AddStaff.jsx +++ b/frontend-web/src/pages/AddStaff.jsx @@ -1,26 +1,90 @@ -import React, { useState } from "react"; -import { base44 } from "@/api/base44Client"; +import React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; +import { krowSDK } from "@/api/krowSDK"; import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; import { ArrowLeft } from "lucide-react"; import StaffForm from "@/components/staff/StaffForm"; export default function AddStaff() { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { toast } = useToast(); const createStaffMutation = useMutation({ - mutationFn: (staffData) => base44.entities.Staff.create(staffData), + mutationFn: (staffPayload) => krowSDK.entities.Staff.create({ data: staffPayload }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['staff'] }); - navigate(createPageUrl("Dashboard")); + toast({ + title: "✅ Staff Member Added", + description: "The new staff member has been successfully created.", + }); + navigate(createPageUrl("StaffDirectory")); + }, + onError: (error) => { + toast({ + title: "❌ Error Creating Staff", + description: error.message || "An unknown error occurred.", + variant: "destructive", + }); }, }); - const handleSubmit = (staffData) => { - createStaffMutation.mutate(staffData); + const handleSubmit = (formData) => { + // 1. Map snake_case from form to camelCase for GraphQL + // 2. Transform enum values to uppercase + // 3. Add required fields not in the form + // 4. Filter out fields not in the mutation + const employmentTypeMap = { + "Full Time": "FULL_TIME", + "Part Time": "PART_TIME", + "On call": "ON_CALL", + "Weekends": "WEEKENDS", + "Specific Days": "SPECIFIC_DAYS", + "Seasonal": "SEASONAL", + "Medical Leave": "MEDICAL_LEAVE", + }; + + const englishLevelMap = { + "Fluent": "FLUENT", + "Intermediate": "INTERMEDIATE", + "Basic": "BASIC", + "None": "NONE", + }; + + const payload = { + // --- Fields from error messages --- + employeeName: formData.employee_name, + employmentType: employmentTypeMap[formData.employment_type], + english: englishLevelMap[formData.english], + backgroundCheckStatus: 'NOT_REQUIRED', // Default as it's missing from form + + // --- Other likely fields (from form) --- + contactNumber: formData.contact_number, + hubLocation: formData.hub_location, + profileType: formData.profile_type, + reliabilityScore: parseInt(formData.reliability_score) || 100, + + // --- Fields from form that might match schema --- + email: formData.email, + position: formData.position, + department: formData.department, + manager: formData.manager, + rate: parseFloat(formData.rate) || 0, + notes: formData.notes, + rating: parseFloat(formData.rating) || 0, + }; + + // Remove any keys with undefined values to keep the payload clean + Object.keys(payload).forEach(key => { + if (payload[key] === undefined || payload[key] === null) { + delete payload[key]; + } + }); + + createStaffMutation.mutate(payload); }; return ( @@ -29,11 +93,11 @@ export default function AddStaff() {

Add New Staff Member

Fill in the details to add a new team member

diff --git a/frontend-web/src/pages/Layout.jsx b/frontend-web/src/pages/Layout.jsx index 1c5ca921..a5e5a21f 100644 --- a/frontend-web/src/pages/Layout.jsx +++ b/frontend-web/src/pages/Layout.jsx @@ -4,6 +4,8 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import { base44 } from "@/api/base44Client"; import { useQuery } from "@tanstack/react-query"; +import { auth } from "@/firebase"; +import { signOut } from "firebase/auth"; import { Users, LayoutDashboard, UserPlus, Calendar, Briefcase, FileText, DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare, @@ -279,7 +281,7 @@ export default function Layout({ children }) { const userInitial = userName.charAt(0).toUpperCase(); const handleLogout = () => { - base44.auth.logout(); + signOut(auth); }; const handleRefresh = () => { @@ -482,6 +484,10 @@ export default function Layout({ children }) { My Profile + + + Logout +
diff --git a/frontend-web/src/pages/Login.jsx b/frontend-web/src/pages/Login.jsx new file mode 100644 index 00000000..c5263c9f --- /dev/null +++ b/frontend-web/src/pages/Login.jsx @@ -0,0 +1,86 @@ +import React, { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { signInWithEmailAndPassword } from "firebase/auth"; +import { auth } from "@/firebase"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +export default function Login() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleLogin = async (e) => { + e.preventDefault(); + setError(null); + + if (!email || !password) { + setError("Email and password are required."); + return; + } + + setLoading(true); + try { + await signInWithEmailAndPassword(auth, email, password); + navigate("/"); + } catch (error) { + setError("Invalid credentials. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Login + Enter your credentials to access your account. + + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} +
+
+
+ + +

+ Don't have an account?{" "} + + Register + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/pages/Register.jsx b/frontend-web/src/pages/Register.jsx new file mode 100644 index 00000000..7e1931e5 --- /dev/null +++ b/frontend-web/src/pages/Register.jsx @@ -0,0 +1,128 @@ +import React, { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { createUserWithEmailAndPassword } from "firebase/auth"; +import { auth } from "@/firebase"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +export default function Register() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const validatePassword = (password) => { + if (password.length < 6) { + return "Password must be at least 6 characters long."; + } + return null; + }; + + const validateEmail = (email) => { + const re = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + if (!re.test(String(email).toLowerCase())) { + return "Invalid email address."; + } + return null; + }; + + const handleRegister = async (e) => { + e.preventDefault(); + setError(null); + + if (!email || !password) { + setError("Email and password are required."); + return; + } + + const emailError = validateEmail(email); + if (emailError) { + setError(emailError); + return; + } + + const passwordError = validatePassword(password); + if (passwordError) { + setError(passwordError); + return; + } + + setLoading(true); + try { + await createUserWithEmailAndPassword(auth, email, password); + navigate("/"); + } catch (error) { + setError(error.message || "Something went wrong. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Register + Create a new account. + + + +
+
+
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="new-password" + /> +
+ + {error &&

{error}

} +
+
+
+ + + + +

+ Already have an account?{" "} + + Login + +

+
+
+
+ ); +} diff --git a/frontend-web/src/pages/StaffDirectory.jsx b/frontend-web/src/pages/StaffDirectory.jsx index 29fda88f..4751c3e2 100644 --- a/frontend-web/src/pages/StaffDirectory.jsx +++ b/frontend-web/src/pages/StaffDirectory.jsx @@ -1,14 +1,14 @@ import React, { useState } from "react"; -import { base44 } from "@/api/base44Client"; import { useQuery } from "@tanstack/react-query"; import { Link } from "react-router-dom"; import { createPageUrl } from "@/utils"; +import { krowSDK } from "@/api/krowSDK"; +import { useAuth } from "@/hooks/useAuth"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { UserPlus, Users, LayoutGrid, List as ListIcon, Phone, MapPin, Calendar, Star } from "lucide-react"; import FilterBar from "@/components/staff/FilterBar"; -import StaffCard from "@/components/staff/StaffCard"; import EmployeeCard from "@/components/staff/EmployeeCard"; import PageHeader from "@/components/common/PageHeader"; @@ -18,50 +18,61 @@ export default function StaffDirectory() { const [locationFilter, setLocationFilter] = useState("all"); const [viewMode, setViewMode] = useState("grid"); // "grid" or "list" - const { data: user } = useQuery({ - queryKey: ['current-user'], - queryFn: () => base44.auth.me(), + const { user: authUser } = useAuth(); // Firebase auth user + + const { data: krowUser, isLoading: isLoadingUser } = useQuery({ + queryKey: ['krow-user', authUser?.uid], + queryFn: () => krowSDK.entities.User.get({ id: authUser.uid }), // Changed from .filter() to .get() + enabled: !!authUser?.uid, + select: (response) => response?.data?.user, // Adjusted to get single user object }); - const { data: staff, isLoading } = useQuery({ + const { data: staff, isLoading: isLoadingStaff } = useQuery({ queryKey: ['staff'], - queryFn: () => base44.entities.Staff.list('-created_date'), + queryFn: () => krowSDK.entities.Staff.list(), initialData: [], + select: (response) => { + // The API returns { data: { staffs: [...] } }, so we need to access the nested array. + if (response && response.data && Array.isArray(response.data.staffs)) { + return response.data.staffs; + } + return []; // Return empty array if the structure is not as expected. + }, }); const { data: events } = useQuery({ queryKey: ['events-for-staff-filter'], - queryFn: () => base44.entities.Event.list(), - initialData: [], - enabled: !!user + queryFn: () => krowSDK.entities.Event.list(), + initialData: { data: [] }, + enabled: !!krowUser, + select: (data) => data.data || [], }); const visibleStaff = React.useMemo(() => { - const userRole = user?.user_role || user?.role; - - if (['admin', 'procurement'].includes(userRole)) { + if (!krowUser || !staff) return []; + const userRole = krowUser.user_role || krowUser.role; + + if (['admin', 'procurement', 'operator', 'sector'].includes(userRole.toLowerCase())) { return staff; } - - if (['operator', 'sector'].includes(userRole)) { - return staff; - } - + if (userRole === 'vendor') { - return staff.filter(s => - s.vendor_id === user?.id || - s.vendor_name === user?.company_name || - s.created_by === user?.email + return staff.filter(s => + s.vendor_id === krowUser.id || + s.vendor_name === krowUser.company_name || + //s.created_by === krowUser.email + e.created_by === krowUser.id ); } - + if (userRole === 'client') { - const clientEvents = events.filter(e => - e.client_email === user?.email || - e.business_name === user?.company_name || - e.created_by === user?.email + const clientEvents = events.filter(e => + e.client_email === krowUser.email || + e.business_name === krowUser.company_name || + //e.created_by === krowUser.email + e.created_by === krowUser.id ); - + const assignedStaffIds = new Set(); clientEvents.forEach(event => { if (event.assigned_staff) { @@ -72,36 +83,37 @@ export default function StaffDirectory() { }); } }); - + return staff.filter(s => assignedStaffIds.has(s.id)); } - + if (userRole === 'workforce') { return staff; } - + return staff; - }, [staff, user, events]); + }, [staff, krowUser, events]); const uniqueDepartments = [...new Set(visibleStaff.map(s => s.department).filter(Boolean))]; const uniqueLocations = [...new Set(visibleStaff.map(s => s.hub_location).filter(Boolean))]; const filteredStaff = visibleStaff.filter(member => { - const matchesSearch = !searchTerm || + const matchesSearch = !searchTerm || member.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) || member.position?.toLowerCase().includes(searchTerm.toLowerCase()) || member.manager?.toLowerCase().includes(searchTerm.toLowerCase()); - + const matchesDepartment = departmentFilter === "all" || member.department === departmentFilter; const matchesLocation = locationFilter === "all" || member.hub_location === locationFilter; - + return matchesSearch && matchesDepartment && matchesLocation; }); - const canAddStaff = ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes(user?.user_role || user?.role); + const canAddStaff = krowUser && ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes((krowUser.user_role || krowUser.role || '').toLowerCase()); + const isLoading = isLoadingStaff || isLoadingUser; const getPageTitle = () => { - const userRole = user?.user_role || user?.role; + const userRole = krowUser?.user_role || krowUser?.role; if (userRole === 'vendor') return "My Staff Directory"; if (userRole === 'client') return "Event Staff Directory"; if (userRole === 'workforce') return "Team Directory"; @@ -109,14 +121,14 @@ export default function StaffDirectory() { }; const getPageSubtitle = () => { - const userRole = user?.user_role || user?.role; + const userRole = krowUser?.user_role || krowUser?.role; if (userRole === 'vendor') return `${filteredStaff.length} of your staff members`; if (userRole === 'client') return `${filteredStaff.length} staff assigned to your events`; if (userRole === 'workforce') return `${filteredStaff.length} team members`; return `${filteredStaff.length} ${filteredStaff.length === 1 ? 'member' : 'members'} found`; }; - - const getCoverageColor = (percentage) => { + + const getCoverageColor = (percentage) => { if (!percentage) return "bg-red-100 text-red-700"; if (percentage >= 90) return "bg-green-100 text-green-700"; if (percentage >= 50) return "bg-yellow-100 text-yellow-700"; @@ -126,7 +138,7 @@ export default function StaffDirectory() { return (
- page.toLowerCase() === urlLastPart.toLowerCase()); - return pageName || Object.keys(PAGES)[0]; + if (url.endsWith('/')) url = url.slice(0, -1); + let last = url.split('/').pop(); + if (last.includes('?')) last = last.split('?')[0]; + const pageName = Object.keys(PAGES).find(p => p.toLowerCase() === last.toLowerCase()); + return pageName || 'Home'; // Default to Home } -// Create a wrapper component that uses useLocation inside the Router context -function PagesContent() { + +function AppRoutes() { const location = useLocation(); const currentPage = _getCurrentPage(location.pathname); - + return ( - - - - } /> - - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - - + + {/* Public Routes */} + } /> + } /> + + {/* Private Routes */} + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + ); } export default function Pages() { return ( - + ); } \ No newline at end of file diff --git a/internal-api-harness/src/api/krowSDK.js b/internal-api-harness/src/api/krowSDK.js index 4cfca950..9bada66c 100644 --- a/internal-api-harness/src/api/krowSDK.js +++ b/internal-api-harness/src/api/krowSDK.js @@ -139,19 +139,30 @@ entityNames.forEach(entityName => { });*/ const dataconnectEntityConfig = { + User: { + list: 'listUsers', + get: 'getUserById', + create: 'createUser', + update: 'updateUser', + delete: 'deleteUser', + filter: 'filterUsers', + }, Event: { list: 'listEvents', create: 'createEvent', - // get: 'getEvent', - // update: 'updateEvent', - // delete: 'deleteEvent', - // filter: 'filterEvents', + get: 'getEventById', + update: 'updateEvent', + delete: 'deleteEvent', + filter: 'filterEvents', }, Staff: { list: 'listStaff', create: 'createStaff', - + get: 'getStaffById', + update: 'updateStaff', + delete: 'deleteStaff', + filter: 'filterStaff', }, Vendor: {